reader.ts

4.4 KB · raw

1import { readdir } from "node:fs/promises";2import { join, relative } from "node:path";3import { pathToFileURL } from "node:url";4import type { BucketSource } from "../server/config.ts";56/**7 * A Koh 3 remote as Kepr uses it (§9.1): read-only on Koh's data, which needs two of its five8 * operations, plus a short-lived download URL so Kepr never streams release files itself (§22.2).9 * Kepr's own files (uploads) live under `KEPR_PREFIX`, the only keys `put` writes (June,10 * 2026-09-29); no Koh repo can have that name, since Kepr and Koh both need a `head` for one.11 */12export type Bucket = {13	get(key: string): Promise<Uint8Array | null>;14	list(prefix: string): Promise<string[]>;15	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */16	downloadUrl(key: string, filename: string, type: string): string;17	/** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */18	viewUrl(key: string, type: string): string | null;19	/** Writes one of Kepr's own files; any key outside `KEPR_PREFIX` is refused. */20	put(key: string, bytes: Uint8Array, type: string): Promise<void>;21};2223export const KEPR_PREFIX = ".kepr/";2425/** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */26export 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	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.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			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).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		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.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}