s3-presign.ts97 lines · main
| 1 | import { S3Client } from 'bun'; |
| 2 | |
| 3 | /** |
| 4 | * S3 presigning + listing, backed by Bun's native S3 client. |
| 5 | * |
| 6 | * Previously this file hand-rolled AWS Signature V4. That implementation was |
| 7 | * untested and produced signatures MinIO rejected (SignatureDoesNotMatch on |
| 8 | * every upload/list). Bun ships a battle-tested S3 client in the runtime, so |
| 9 | * we delegate to it — no extra dependency, correct signatures against MinIO / |
| 10 | * Garage / R2 / real S3. We construct a client per call because callers pass |
| 11 | * different endpoints (internal `minio:9000` for server-side ops, the public |
| 12 | * endpoint for browser-facing presigned URLs). |
| 13 | */ |
| 14 | |
| 15 | export interface PresignInput { |
| 16 | endpoint: string; // e.g. "https://s3.briven.tech" or "http://minio:9000" |
| 17 | region: string; // e.g. "us-east-1" |
| 18 | bucket: string; |
| 19 | key: string; // object key, NOT url-encoded |
| 20 | method: 'PUT' | 'GET' | 'DELETE'; |
| 21 | accessKey: string; |
| 22 | secretKey: string; |
| 23 | expiresIn: number; // seconds; max 604800 (7 days) |
| 24 | /** For PUT — ties the Content-Type into the signed URL. */ |
| 25 | contentType?: string; |
| 26 | } |
| 27 | |
| 28 | function clientFor(input: { |
| 29 | endpoint: string; |
| 30 | region: string; |
| 31 | bucket: string; |
| 32 | accessKey: string; |
| 33 | secretKey: string; |
| 34 | }): S3Client { |
| 35 | return new S3Client({ |
| 36 | endpoint: input.endpoint, |
| 37 | region: input.region, |
| 38 | bucket: input.bucket, |
| 39 | accessKeyId: input.accessKey, |
| 40 | secretAccessKey: input.secretKey, |
| 41 | }); |
| 42 | } |
| 43 | |
| 44 | export function presignS3Url(input: PresignInput): string { |
| 45 | if (input.expiresIn < 1 || input.expiresIn > 604_800) { |
| 46 | throw new Error('expiresIn must be 1..604800 seconds'); |
| 47 | } |
| 48 | const client = clientFor(input); |
| 49 | return client.presign(input.key, { |
| 50 | method: input.method, |
| 51 | expiresIn: input.expiresIn, |
| 52 | ...(input.method === 'PUT' && input.contentType ? { type: input.contentType } : {}), |
| 53 | }); |
| 54 | } |
| 55 | |
| 56 | export interface ListObjectsInput { |
| 57 | endpoint: string; |
| 58 | region: string; |
| 59 | bucket: string; |
| 60 | accessKey: string; |
| 61 | secretKey: string; |
| 62 | prefix?: string; |
| 63 | /** AWS caps at 1000 per request. */ |
| 64 | maxKeys?: number; |
| 65 | /** Key to start after — used for pagination (Bun list is startAfter-based). */ |
| 66 | startAfter?: string; |
| 67 | } |
| 68 | |
| 69 | export interface ListObjectsResult { |
| 70 | objects: Array<{ key: string; lastModified: Date }>; |
| 71 | isTruncated: boolean; |
| 72 | /** Pass back as `startAfter` to fetch the next page; null when done. */ |
| 73 | nextStartAfter: string | null; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * List objects under a prefix. Replaces the old presign-a-list-URL + fetch + |
| 78 | * regex-parse-XML dance — Bun's client does the signed request and parses the |
| 79 | * response for us. |
| 80 | */ |
| 81 | export async function listObjects(input: ListObjectsInput): Promise<ListObjectsResult> { |
| 82 | const client = clientFor(input); |
| 83 | const res = await client.list({ |
| 84 | prefix: input.prefix, |
| 85 | maxKeys: input.maxKeys, |
| 86 | startAfter: input.startAfter, |
| 87 | }); |
| 88 | const objects = (res.contents ?? []).map((o) => ({ |
| 89 | key: o.key, |
| 90 | // Missing timestamp → treat as "now" so it's never mistaken for an old orphan. |
| 91 | lastModified: new Date(o.lastModified ?? Date.now()), |
| 92 | })); |
| 93 | const isTruncated = Boolean(res.isTruncated); |
| 94 | const nextStartAfter = |
| 95 | isTruncated && objects.length > 0 ? (objects[objects.length - 1]?.key ?? null) : null; |
| 96 | return { objects, isTruncated, nextStartAfter }; |
| 97 | } |