s3-presign.ts97 lines · main
1import { 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
15export 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
28function 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
44export 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
56export 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
69export 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 */
81export 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}