reader.ts

6.2 KB · raw

1import { readdir, unlink } 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` (June, 2026-09-29); no Koh repo can have10 * that name, since Kepr and Koh both need a `head` for one. Beyond it Kepr touches only builds,11 * which Koh has no way to delete (June, 2026-09-30): it deletes a version's files and moves12 * `releases/latest`.13 */14export type Bucket = {15	get(key: string): Promise<Uint8Array | null>;16	list(prefix: string): Promise<string[]>;17	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */18	downloadUrl(key: string, filename: string, type: string): string;19	/** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */20	viewUrl(key: string, type: string): string | null;21	/** Writes one of Kepr's own files or a repo's `releases/latest`; any other key is refused. */22	put(key: string, bytes: Uint8Array, type: string): Promise<void>;23	/** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */24	remove(key: string): Promise<void>;25};2627export const KEPR_PREFIX = ".kepr/";2829/** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */30export class BucketWriteError extends Error {}3132const DOWNLOAD_SECONDS = 5 * 60;3334const RELEASES_LATEST = /^[^/]+\/releases\/latest$/;35const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;3637function requireWritable(key: string, releaseKey: RegExp): void {38	const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);39	if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {40		throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`);41	}42}4344export function openBucket(source: BucketSource): Bucket {45	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);46}4748type S3Source = Extract<BucketSource, { kind: "s3" }>;4950/**51 * The console's connection test (§24), made before settings are saved: the read key must list the52 * bucket, and a write key must write Kepr's own test file. Returns what failed, or null.53 */54export async function checkS3Access(source: S3Source): Promise<string | null> {55	const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));56	try {57		// One key is proof enough; listing the whole bucket could take minutes.58		await s3Client(source).list({ maxKeys: 1 });59	} catch (error) {60		return `The read key cannot list the bucket: ${reason(error)}`;61	}62	if (!source.write) return null;63	try {64		const stamp = new TextEncoder().encode(new Date().toISOString());65		await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");66	} catch (error) {67		return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;68	}69	return null;70}7172function s3Client(73	source: S3Source,74	keys: { accessKeyId: string; secretAccessKey: string } = source,75): Bun.S3Client {76	return new Bun.S3Client({77		endpoint: source.endpoint,78		bucket: source.name,79		region: source.region,80		accessKeyId: keys.accessKeyId,81		secretAccessKey: keys.secretAccessKey,82	});83}8485function s3Reader(source: S3Source): Bucket {86	const client = s3Client(source);87	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.88	const writer = source.write && s3Client(source, source.write);89	const requireWriter = () => {90		if (!writer) {91			throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");92		}93		return writer;94	};95	return {96		async get(key) {97			try {98				return await client.file(key).bytes();99			} catch (error) {100				if ((error as { code?: string }).code === "NoSuchKey") return null;101				throw error;102			}103		},104		async list(prefix) {105			const keys: string[] = [];106			let continuationToken: string | undefined;107			do {108				const page = await client.list({ prefix, continuationToken });109				for (const object of page.contents ?? []) keys.push(object.key);110				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;111			} while (continuationToken);112			return keys;113		},114		downloadUrl(key, filename, type) {115			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).116			return client.presign(key, {117				method: "GET",118				expiresIn: DOWNLOAD_SECONDS,119				contentDisposition: `attachment; filename="${filename}"`,120				type,121			});122		},123		viewUrl(key, type) {124			return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });125		},126		async put(key, bytes, type) {127			requireWritable(key, RELEASES_LATEST);128			await requireWriter().file(key).write(bytes, { type });129		},130		async remove(key) {131			requireWritable(key, RELEASE_FILE);132			await requireWriter().file(key).delete();133		},134	};135}136137function folderReader(root: string): Bucket {138	return {139		async get(key) {140			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);141			const file = Bun.file(join(root, key));142			return (await file.exists()) ? await file.bytes() : null;143		},144		async list(prefix) {145			const entries = await readdir(root, { recursive: true, withFileTypes: true });146			return entries147				.filter((entry) => entry.isFile())148				.map((entry) => relative(root, join(entry.parentPath, entry.name)))149				.filter((key) => key.startsWith(prefix))150				.sort();151		},152		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.153		downloadUrl(key) {154			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);155			return pathToFileURL(join(root, key)).href;156		},157		viewUrl: () => null,158		async put(key, bytes) {159			requireWritable(key, RELEASES_LATEST);160			await Bun.write(join(root, key), bytes);161		},162		async remove(key) {163			requireWritable(key, RELEASE_FILE);164			await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {165				if (error.code !== "ENOENT") throw error;166			});167		},168	};169}