1import { readdir } from "node:fs/promises";2import { join, relative } from "node:path";3import { pathToFileURL } from "node:url";4import type { BucketSource } from "../server/config.ts";56789101112export type Bucket = {13 get(key: string): Promise<Uint8Array | null>;14 list(prefix: string): Promise<string[]>;15 16 downloadUrl(key: string, filename: string, type: string): string;17 18 viewUrl(key: string, type: string): string | null;19 20 put(key: string, bytes: Uint8Array, type: string): Promise<void>;21};2223export const KEPR_PREFIX = ".kepr/";242526export class BucketWriteError extends Error {}2728const DOWNLOAD_SECONDS = 5 * 60;2930function requireOwnKey(key: string): void {31 if (!key.startsWith(KEPR_PREFIX) || key.split("/").some((part) => part === ".." || part === "")) {32 throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX}, not "${key}"`);33 }34}3536export function openBucket(source: BucketSource): Bucket {37 return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);38}3940function s3Reader(source: Extract<BucketSource, { kind: "s3" }>): Bucket {41 const client = new Bun.S3Client({42 endpoint: source.endpoint,43 bucket: source.name,44 region: source.region,45 accessKeyId: source.accessKeyId,46 secretAccessKey: source.secretAccessKey,47 });48 49 const writer =50 source.write &&51 new Bun.S3Client({52 endpoint: source.endpoint,53 bucket: source.name,54 region: source.region,55 ...source.write,56 });57 return {58 async get(key) {59 try {60 return await client.file(key).bytes();61 } catch (error) {62 if ((error as { code?: string }).code === "NoSuchKey") return null;63 throw error;64 }65 },66 async list(prefix) {67 const keys: string[] = [];68 let continuationToken: string | undefined;69 do {70 const page = await client.list({ prefix, continuationToken });71 for (const object of page.contents ?? []) keys.push(object.key);72 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;73 } while (continuationToken);74 return keys;75 },76 downloadUrl(key, filename, type) {77 78 return client.presign(key, {79 method: "GET",80 expiresIn: DOWNLOAD_SECONDS,81 contentDisposition: `attachment; filename="${filename}"`,82 type,83 });84 },85 viewUrl(key, type) {86 return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });87 },88 async put(key, bytes, type) {89 requireOwnKey(key);90 if (!writer) {91 throw new BucketWriteError("Uploads need the bucket's write key: add [bucket.write].");92 }93 await writer.file(key).write(bytes, { type });94 },95 };96}9798function folderReader(root: string): Bucket {99 return {100 async get(key) {101 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);102 const file = Bun.file(join(root, key));103 return (await file.exists()) ? await file.bytes() : null;104 },105 async list(prefix) {106 const entries = await readdir(root, { recursive: true, withFileTypes: true });107 return entries108 .filter((entry) => entry.isFile())109 .map((entry) => relative(root, join(entry.parentPath, entry.name)))110 .filter((key) => key.startsWith(prefix))111 .sort();112 },113 114 downloadUrl(key) {115 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);116 return pathToFileURL(join(root, key)).href;117 },118 viewUrl: () => null,119 async put(key, bytes) {120 requireOwnKey(key);121 await Bun.write(join(root, key), bytes);122 },123 };124}