Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 19:36:14 -04:00
co-authored by Cursor
commit 27e2f196eb
149 changed files with 13847 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@album/storage",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.864.0",
"@aws-sdk/s3-request-presigner": "^3.864.0",
"dotenv": "^16.5.0"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+15
View File
@@ -0,0 +1,15 @@
export {
createPresignedGetUrl,
createPresignedPutUrl,
deletePrefix,
ensureBucket,
getObjectBuffer,
headObject,
putObject,
} from "./s3";
export {
displayObjectKey,
originalObjectKey,
photoObjectPrefix,
thumbObjectKey,
} from "./keys";
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import {
displayObjectKey,
originalObjectKey,
photoObjectPrefix,
thumbObjectKey,
} from "./keys";
describe("object keys", () => {
const eventId = "11111111-1111-4111-8111-111111111111";
const photoId = "22222222-2222-4222-8222-222222222222";
test("keeps originals and variants under the same photo prefix", () => {
const prefix = photoObjectPrefix(eventId, photoId);
expect(originalObjectKey(eventId, photoId).startsWith(prefix)).toBe(true);
expect(displayObjectKey(eventId, photoId)).toBe(`${prefix}display.jpg`);
expect(thumbObjectKey(eventId, photoId)).toBe(`${prefix}thumb.jpg`);
});
});
+15
View File
@@ -0,0 +1,15 @@
export function originalObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/original`;
}
export function displayObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/display.jpg`;
}
export function thumbObjectKey(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/thumb.jpg`;
}
export function photoObjectPrefix(eventId: string, photoId: string) {
return `events/${eventId}/photos/${photoId}/`;
}
+200
View File
@@ -0,0 +1,200 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { config } from "dotenv";
import {
CreateBucketCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutBucketCorsCommand,
PutObjectCommand,
S3Client,
type HeadObjectCommandOutput,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
for (const path of [
resolve(process.cwd(), ".env"),
resolve(process.cwd(), "../../.env"),
]) {
if (existsSync(path)) {
config({ path, override: false });
break;
}
}
const PRESIGN_PUT_SECONDS = 10 * 60;
const PRESIGN_GET_SECONDS = 60 * 60;
function requiredEnv(name: string) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
}
export function storageConfig() {
return {
endpoint: requiredEnv("S3_ENDPOINT"),
publicEndpoint:
process.env.S3_PUBLIC_ENDPOINT?.trim() || requiredEnv("S3_ENDPOINT"),
region: process.env.S3_REGION?.trim() || "us-east-1",
bucket: requiredEnv("S3_BUCKET"),
accessKey: requiredEnv("S3_ACCESS_KEY"),
secretKey: requiredEnv("S3_SECRET_KEY"),
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
};
}
function createClient(endpoint: string) {
const config = storageConfig();
return new S3Client({
region: config.region,
endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
});
}
let signingClient: S3Client | undefined;
let publicClient: S3Client | undefined;
let bucketReady = false;
export function getSigningClient() {
signingClient ??= createClient(storageConfig().endpoint);
return signingClient;
}
export function getPublicClient() {
publicClient ??= createClient(storageConfig().publicEndpoint);
return publicClient;
}
export async function ensureBucket() {
if (bucketReady) return;
const client = getSigningClient();
const { bucket } = storageConfig();
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
await client.send(new CreateBucketCommand({ Bucket: bucket }));
}
try {
await client.send(
new PutBucketCorsCommand({
Bucket: bucket,
CORSConfiguration: {
CORSRules: [
{
AllowedHeaders: ["*"],
AllowedMethods: ["GET", "PUT", "HEAD"],
AllowedOrigins: ["*"],
ExposeHeaders: ["ETag", "Content-Length"],
MaxAgeSeconds: 3600,
},
],
},
}),
);
} catch (error) {
console.warn("Could not set bucket CORS", error);
}
bucketReady = true;
}
export async function createPresignedPutUrl(input: {
key: string;
contentType: string;
}) {
await ensureBucket();
const { bucket } = storageConfig();
return getSignedUrl(
getPublicClient(),
new PutObjectCommand({
Bucket: bucket,
Key: input.key,
ContentType: input.contentType,
}),
{ expiresIn: PRESIGN_PUT_SECONDS },
);
}
export async function createPresignedGetUrl(key: string) {
await ensureBucket();
const { bucket } = storageConfig();
return getSignedUrl(
getPublicClient(),
new GetObjectCommand({
Bucket: bucket,
Key: key,
}),
{ expiresIn: PRESIGN_GET_SECONDS },
);
}
export async function headObject(
key: string,
): Promise<HeadObjectCommandOutput | null> {
await ensureBucket();
const { bucket } = storageConfig();
try {
return await getSigningClient().send(
new HeadObjectCommand({ Bucket: bucket, Key: key }),
);
} catch {
return null;
}
}
export async function getObjectBuffer(key: string) {
await ensureBucket();
const { bucket } = storageConfig();
const result = await getSigningClient().send(
new GetObjectCommand({ Bucket: bucket, Key: key }),
);
if (!result.Body) throw new Error(`Object ${key} has no body`);
return Buffer.from(await result.Body.transformToByteArray());
}
export async function putObject(input: {
key: string;
body: Buffer;
contentType: string;
}) {
await ensureBucket();
const { bucket } = storageConfig();
await getSigningClient().send(
new PutObjectCommand({
Bucket: bucket,
Key: input.key,
Body: input.body,
ContentType: input.contentType,
}),
);
}
export async function deletePrefix(prefix: string) {
await ensureBucket();
const { bucket } = storageConfig();
const listed = await getSigningClient().send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }),
);
const keys = (listed.Contents ?? [])
.map((object) => object.Key)
.filter((key): key is string => Boolean(key));
if (keys.length === 0) return;
await getSigningClient().send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: keys.map((Key) => ({ Key })),
},
}),
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts"]
}