reader.ts

6.3 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, rewrites its12 * record without a deleted one, and moves `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	/**22	 * Writes one of Kepr's own files, a repo's `releases/latest`, or a version's `release.json` when23	 * one of its files is deleted; any other key is refused.24	 */25	put(key: string, bytes: Uint8Array, type: string): Promise<void>;26	/** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */27	remove(key: string): Promise<void>;28};2930export const KEPR_PREFIX = ".kepr/";3132/** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */33export class BucketWriteError extends Error {}3435const DOWNLOAD_SECONDS = 5 * 60;3637const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/;38const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;3940function requireWritable(key: string, releaseKey: RegExp): void {41	const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);42	if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {43		throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`);44	}45}4647export function openBucket(source: BucketSource): Bucket {48	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);49}5051type S3Source = Extract<BucketSource, { kind: "s3" }>;5253/**54 * The console's connection test (§24), made before settings are saved: the read key must list the55 * bucket, and a write key must write Kepr's own test file. Returns what failed, or null.56 */57export async function checkS3Access(source: S3Source): Promise<string | null> {58	const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));59	try {60		// One key is proof enough; listing the whole bucket could take minutes.61		await s3Client(source).list({ maxKeys: 1 });62	} catch (error) {63		return `The read key cannot list the bucket: ${reason(error)}`;64	}65	if (!source.write) return null;66	try {67		const stamp = new TextEncoder().encode(new Date().toISOString());68		await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");69	} catch (error) {70		return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;71	}72	return null;73}7475function s3Client(76	source: S3Source,77	keys: { accessKeyId: string; secretAccessKey: string } = source,78): Bun.S3Client {79	return new Bun.S3Client({80		endpoint: source.endpoint,81		bucket: source.name,82		region: source.region,83		accessKeyId: keys.accessKeyId,84		secretAccessKey: keys.secretAccessKey,85	});86}8788function s3Reader(source: S3Source): Bucket {89	const client = s3Client(source);90	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.91	const writer = source.write && s3Client(source, source.write);92	const requireWriter = () => {93		if (!writer) {94			throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");95		}96		return writer;97	};98	return {99		async get(key) {100			try {101				return await client.file(key).bytes();102			} catch (error) {103				if ((error as { code?: string }).code === "NoSuchKey") return null;104				throw error;105			}106		},107		async list(prefix) {108			const keys: string[] = [];109			let continuationToken: string | undefined;110			do {111				const page = await client.list({ prefix, continuationToken });112				for (const object of page.contents ?? []) keys.push(object.key);113				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;114			} while (continuationToken);115			return keys;116		},117		downloadUrl(key, filename, type) {118			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).119			return client.presign(key, {120				method: "GET",121				expiresIn: DOWNLOAD_SECONDS,122				contentDisposition: `attachment; filename="${filename}"`,123				type,124			});125		},126		viewUrl(key, type) {127			return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });128		},129		async put(key, bytes, type) {130			requireWritable(key, RELEASE_WRITE);131			await requireWriter().file(key).write(bytes, { type });132		},133		async remove(key) {134			requireWritable(key, RELEASE_FILE);135			await requireWriter().file(key).delete();136		},137	};138}139140function folderReader(root: string): Bucket {141	return {142		async get(key) {143			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);144			const file = Bun.file(join(root, key));145			return (await file.exists()) ? await file.bytes() : null;146		},147		async list(prefix) {148			const entries = await readdir(root, { recursive: true, withFileTypes: true });149			return entries150				.filter((entry) => entry.isFile())151				.map((entry) => relative(root, join(entry.parentPath, entry.name)))152				.filter((key) => key.startsWith(prefix))153				.sort();154		},155		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.156		downloadUrl(key) {157			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);158			return pathToFileURL(join(root, key)).href;159		},160		viewUrl: () => null,161		async put(key, bytes) {162			requireWritable(key, RELEASE_WRITE);163			await Bun.write(join(root, key), bytes);164		},165		async remove(key) {166			requireWritable(key, RELEASE_FILE);167			await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {168				if (error.code !== "ENOENT") throw error;169			});170		},171	};172}