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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
createPresignedGetUrl,
|
||||
createPresignedPutUrl,
|
||||
deletePrefix,
|
||||
ensureBucket,
|
||||
getObjectBuffer,
|
||||
headObject,
|
||||
putObject,
|
||||
} from "./s3";
|
||||
export {
|
||||
displayObjectKey,
|
||||
originalObjectKey,
|
||||
photoObjectPrefix,
|
||||
thumbObjectKey,
|
||||
} from "./keys";
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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}/`;
|
||||
}
|
||||
@@ -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 })),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user