1import { readdir } from "node:fs/promises";2import { join, relative } from "node:path";3import { pathToFileURL } from "node:url";4import type { BucketSource } from "../server/config.ts";5678910export type BucketReader = {11 get(key: string): Promise<Uint8Array | null>;12 list(prefix: string): Promise<string[]>;13 14 downloadUrl(key: string, filename: string, type: string): string;15};1617const DOWNLOAD_SECONDS = 5 * 60;1819export function openBucket(source: BucketSource): BucketReader {20 return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);21}2223function s3Reader(source: Extract<BucketSource, { kind: "s3" }>): BucketReader {24 const client = new Bun.S3Client({25 endpoint: source.endpoint,26 bucket: source.name,27 region: source.region,28 accessKeyId: source.accessKeyId,29 secretAccessKey: source.secretAccessKey,30 });31 return {32 async get(key) {33 try {34 return await client.file(key).bytes();35 } catch (error) {36 if ((error as { code?: string }).code === "NoSuchKey") return null;37 throw error;38 }39 },40 async list(prefix) {41 const keys: string[] = [];42 let continuationToken: string | undefined;43 do {44 const page = await client.list({ prefix, continuationToken });45 for (const object of page.contents ?? []) keys.push(object.key);46 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;47 } while (continuationToken);48 return keys;49 },50 downloadUrl(key, filename, type) {51 52 return client.presign(key, {53 method: "GET",54 expiresIn: DOWNLOAD_SECONDS,55 contentDisposition: `attachment; filename="${filename}"`,56 type,57 });58 },59 };60}6162function folderReader(root: string): BucketReader {63 return {64 async get(key) {65 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);66 const file = Bun.file(join(root, key));67 return (await file.exists()) ? await file.bytes() : null;68 },69 async list(prefix) {70 const entries = await readdir(root, { recursive: true, withFileTypes: true });71 return entries72 .filter((entry) => entry.isFile())73 .map((entry) => relative(root, join(entry.parentPath, entry.name)))74 .filter((key) => key.startsWith(prefix))75 .sort();76 },77 78 downloadUrl(key) {79 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);80 return pathToFileURL(join(root, key)).href;81 },82 };83}