reader.ts

5.3 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}3940type S3Source = Extract<BucketSource, { kind: "s3" }>;4142/**43 * The console's connection test (§24), made before settings are saved: the read key must list the44 * bucket, and a write key must write Kepr's own test file. Returns what failed, or null.45 */46export async function checkS3Access(source: S3Source): Promise<string | null> {47	const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));48	try {49		// One key is proof enough; listing the whole bucket could take minutes.50		await s3Client(source).list({ maxKeys: 1 });51	} catch (error) {52		return `The read key cannot list the bucket: ${reason(error)}`;53	}54	if (!source.write) return null;55	try {56		const stamp = new TextEncoder().encode(new Date().toISOString());57		await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");58	} catch (error) {59		return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;60	}61	return null;62}6364function s3Client(65	source: S3Source,66	keys: { accessKeyId: string; secretAccessKey: string } = source,67): Bun.S3Client {68	return new Bun.S3Client({69		endpoint: source.endpoint,70		bucket: source.name,71		region: source.region,72		accessKeyId: keys.accessKeyId,73		secretAccessKey: keys.secretAccessKey,74	});75}7677function s3Reader(source: S3Source): Bucket {78	const client = s3Client(source);79	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.80	const writer = source.write && s3Client(source, source.write);81	return {82		async get(key) {83			try {84				return await client.file(key).bytes();85			} catch (error) {86				if ((error as { code?: string }).code === "NoSuchKey") return null;87				throw error;88			}89		},90		async list(prefix) {91			const keys: string[] = [];92			let continuationToken: string | undefined;93			do {94				const page = await client.list({ prefix, continuationToken });95				for (const object of page.contents ?? []) keys.push(object.key);96				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;97			} while (continuationToken);98			return keys;99		},100		downloadUrl(key, filename, type) {101			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).102			return client.presign(key, {103				method: "GET",104				expiresIn: DOWNLOAD_SECONDS,105				contentDisposition: `attachment; filename="${filename}"`,106				type,107			});108		},109		viewUrl(key, type) {110			return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });111		},112		async put(key, bytes, type) {113			requireOwnKey(key);114			if (!writer) {115				throw new BucketWriteError("Uploads need the bucket's write key: add [bucket.write].");116			}117			await writer.file(key).write(bytes, { type });118		},119	};120}121122function folderReader(root: string): Bucket {123	return {124		async get(key) {125			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);126			const file = Bun.file(join(root, key));127			return (await file.exists()) ? await file.bytes() : null;128		},129		async list(prefix) {130			const entries = await readdir(root, { recursive: true, withFileTypes: true });131			return entries132				.filter((entry) => entry.isFile())133				.map((entry) => relative(root, join(entry.parentPath, entry.name)))134				.filter((key) => key.startsWith(prefix))135				.sort();136		},137		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.138		downloadUrl(key) {139			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);140			return pathToFileURL(join(root, key)).href;141		},142		viewUrl: () => null,143		async put(key, bytes) {144			requireOwnKey(key);145			await Bun.write(join(root, key), bytes);146		},147	};148}