reader.ts

2.8 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 * Read-only view of a Koh 3 remote (§9.1): Kepr needs only two of its five operations, plus a8 * short-lived download URL so it never streams release files itself (§22.2).9 */10export type BucketReader = {11	get(key: string): Promise<Uint8Array | null>;12	list(prefix: string): Promise<string[]>;13	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */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			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).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		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.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}