import { readdir } from "node:fs/promises"; import { join, relative } from "node:path"; import type { BucketSource } from "../server/config.ts"; /** Read-only view of a Koh 3 remote (ยง9.1): Kepr needs only these two of its five operations. */ export type BucketReader = { get(key: string): Promise; list(prefix: string): Promise; }; export function openBucket(source: BucketSource): BucketReader { return source.kind === "s3" ? s3Reader(source) : folderReader(source.path); } function s3Reader(source: Extract): BucketReader { const client = new Bun.S3Client({ endpoint: source.endpoint, bucket: source.name, region: source.region, accessKeyId: source.accessKeyId, secretAccessKey: source.secretAccessKey, }); return { async get(key) { try { return await client.file(key).bytes(); } catch (error) { if ((error as { code?: string }).code === "NoSuchKey") return null; throw error; } }, async list(prefix) { const keys: string[] = []; let continuationToken: string | undefined; do { const page = await client.list({ prefix, continuationToken }); for (const object of page.contents ?? []) keys.push(object.key); continuationToken = page.isTruncated ? page.nextContinuationToken : undefined; } while (continuationToken); return keys; }, }; } function folderReader(root: string): BucketReader { return { async get(key) { if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`); const file = Bun.file(join(root, key)); return (await file.exists()) ? await file.bytes() : null; }, async list(prefix) { const entries = await readdir(root, { recursive: true, withFileTypes: true }); return entries .filter((entry) => entry.isFile()) .map((entry) => relative(root, join(entry.parentPath, entry.name))) .filter((key) => key.startsWith(prefix)) .sort(); }, }; }