reader.ts

7.0 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	/** Every key under `prefix` with its size in bytes, for the console's storage view. */18	sizes(prefix: string): Promise<Map<string, number>>;19	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */20	downloadUrl(key: string, filename: string, type: string): string;21	/** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */22	viewUrl(key: string, type: string): string | null;23	/**24	 * Writes one of Kepr's own files, a repo's `releases/latest`, or a version's `release.json` when25	 * one of its files is deleted; any other key is refused.26	 */27	put(key: string, bytes: Uint8Array, type: string): Promise<void>;28	/** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */29	remove(key: string): Promise<void>;30};3132export const KEPR_PREFIX = ".kepr/";3334/** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */35export class BucketWriteError extends Error {}3637const DOWNLOAD_SECONDS = 5 * 60;3839const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/;40const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;4142function requireWritable(key: string, releaseKey: RegExp): void {43	const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);44	if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {45		throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`);46	}47}4849export function openBucket(source: BucketSource): Bucket {50	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);51}5253type S3Source = Extract<BucketSource, { kind: "s3" }>;5455/**56 * The console's connection test (§24), made before settings are saved: the read key must list the57 * bucket, and a write key must write Kepr's own test file. Returns what failed, or null.58 */59export async function checkS3Access(source: S3Source): Promise<string | null> {60	const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));61	try {62		// One key is proof enough; listing the whole bucket could take minutes.63		await s3Client(source).list({ maxKeys: 1 });64	} catch (error) {65		return `The read key cannot list the bucket: ${reason(error)}`;66	}67	if (!source.write) return null;68	try {69		const stamp = new TextEncoder().encode(new Date().toISOString());70		await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");71	} catch (error) {72		return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;73	}74	return null;75}7677function s3Client(78	source: S3Source,79	keys: { accessKeyId: string; secretAccessKey: string } = source,80): Bun.S3Client {81	return new Bun.S3Client({82		endpoint: source.endpoint,83		bucket: source.name,84		region: source.region,85		accessKeyId: keys.accessKeyId,86		secretAccessKey: keys.secretAccessKey,87	});88}8990function s3Reader(source: S3Source): Bucket {91	const client = s3Client(source);92	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.93	const writer = source.write && s3Client(source, source.write);94	const requireWriter = () => {95		if (!writer) {96			throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");97		}98		return writer;99	};100	return {101		async get(key) {102			try {103				return await client.file(key).bytes();104			} catch (error) {105				if ((error as { code?: string }).code === "NoSuchKey") return null;106				throw error;107			}108		},109		async list(prefix) {110			const keys: string[] = [];111			let continuationToken: string | undefined;112			do {113				const page = await client.list({ prefix, continuationToken });114				for (const object of page.contents ?? []) keys.push(object.key);115				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;116			} while (continuationToken);117			return keys;118		},119		async sizes(prefix) {120			const sizes = new Map<string, number>();121			let continuationToken: string | undefined;122			do {123				const page = await client.list({ prefix, continuationToken });124				for (const object of page.contents ?? []) sizes.set(object.key, object.size ?? 0);125				continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;126			} while (continuationToken);127			return sizes;128		},129		downloadUrl(key, filename, type) {130			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).131			return client.presign(key, {132				method: "GET",133				expiresIn: DOWNLOAD_SECONDS,134				contentDisposition: `attachment; filename="${filename}"`,135				type,136			});137		},138		viewUrl(key, type) {139			return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });140		},141		async put(key, bytes, type) {142			requireWritable(key, RELEASE_WRITE);143			await requireWriter().file(key).write(bytes, { type });144		},145		async remove(key) {146			requireWritable(key, RELEASE_FILE);147			await requireWriter().file(key).delete();148		},149	};150}151152function folderReader(root: string): Bucket {153	const list = async (prefix: string) => {154		const entries = await readdir(root, { recursive: true, withFileTypes: true });155		return entries156			.filter((entry) => entry.isFile())157			.map((entry) => relative(root, join(entry.parentPath, entry.name)))158			.filter((key) => key.startsWith(prefix))159			.sort();160	};161	return {162		async get(key) {163			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);164			const file = Bun.file(join(root, key));165			return (await file.exists()) ? await file.bytes() : null;166		},167		list,168		async sizes(prefix) {169			return new Map((await list(prefix)).map((key) => [key, Bun.file(join(root, key)).size]));170		},171		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.172		downloadUrl(key) {173			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);174			return pathToFileURL(join(root, key)).href;175		},176		viewUrl: () => null,177		async put(key, bytes) {178			requireWritable(key, RELEASE_WRITE);179			await Bun.write(join(root, key), bytes);180		},181		async remove(key) {182			requireWritable(key, RELEASE_FILE);183			await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {184				if (error.code !== "ENOENT") throw error;185			});186		},187	};188}