import { readdir, unlink } from "node:fs/promises"; import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import type { BucketSource } from "../server/config.ts"; /** * A Koh 3 remote as Kepr uses it (§9.1): read-only on Koh's data, which needs two of its five * operations, plus a short-lived download URL so Kepr never streams release files itself (§22.2). * Kepr's own files (uploads) live under `KEPR_PREFIX` (June, 2026-09-29); no Koh repo can have * that name, since Kepr and Koh both need a `head` for one. Beyond it Kepr touches only builds, * which Koh has no way to delete (June, 2026-09-30): it deletes a version's files, rewrites its * record without a deleted one, and moves `releases/latest`. */ export type Bucket = { get(key: string): Promise; list(prefix: string): Promise; /** Every key under `prefix` with its size in bytes, for the console's storage view. */ sizes(prefix: string): Promise>; /** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */ downloadUrl(key: string, filename: string, type: string): string; /** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */ viewUrl(key: string, type: string): string | null; /** * Writes one of Kepr's own files, a repo's `releases/latest`, or a version's `release.json` when * one of its files is deleted; any other key is refused. */ put(key: string, bytes: Uint8Array, type: string): Promise; /** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */ remove(key: string): Promise; }; export const KEPR_PREFIX = ".kepr/"; /** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */ export class BucketWriteError extends Error {} const DOWNLOAD_SECONDS = 5 * 60; const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/; const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/; function requireWritable(key: string, releaseKey: RegExp): void { const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key); if (!allowed || key.split("/").some((part) => part === ".." || part === "")) { throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`); } } export function openBucket(source: BucketSource): Bucket { return source.kind === "s3" ? s3Reader(source) : folderReader(source.path); } type S3Source = Extract; /** * The console's connection test (§24), made before settings are saved: the read key must list the * bucket, and a write key must write Kepr's own test file. Returns what failed, or null. */ export async function checkS3Access(source: S3Source): Promise { const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)); try { // One key is proof enough; listing the whole bucket could take minutes. await s3Client(source).list({ maxKeys: 1 }); } catch (error) { return `The read key cannot list the bucket: ${reason(error)}`; } if (!source.write) return null; try { const stamp = new TextEncoder().encode(new Date().toISOString()); await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain"); } catch (error) { return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`; } return null; } function s3Client( source: S3Source, keys: { accessKeyId: string; secretAccessKey: string } = source, ): Bun.S3Client { return new Bun.S3Client({ endpoint: source.endpoint, bucket: source.name, region: source.region, accessKeyId: keys.accessKeyId, secretAccessKey: keys.secretAccessKey, }); } function s3Reader(source: S3Source): Bucket { const client = s3Client(source); // Reads keep the read-only key; only Kepr's own writes use the write key, when there is one. const writer = source.write && s3Client(source, source.write); const requireWriter = () => { if (!writer) { throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write]."); } return writer; }; return { async get(key) { try { return await client.file(key).bytes(); } catch (error) { if ((error as { code?: string }).code === "NoSuchKey") return null; throw error; } }, async list(prefix) { const keys: string[] = []; let continuationToken: string | undefined; do { const page = await client.list({ prefix, continuationToken }); for (const object of page.contents ?? []) keys.push(object.key); continuationToken = page.isTruncated ? page.nextContinuationToken : undefined; } while (continuationToken); return keys; }, async sizes(prefix) { const sizes = new Map(); let continuationToken: string | undefined; do { const page = await client.list({ prefix, continuationToken }); for (const object of page.contents ?? []) sizes.set(object.key, object.size ?? 0); continuationToken = page.isTruncated ? page.nextContinuationToken : undefined; } while (continuationToken); return sizes; }, downloadUrl(key, filename, type) { // Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14). return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, contentDisposition: `attachment; filename="${filename}"`, type, }); }, viewUrl(key, type) { return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type }); }, async put(key, bytes, type) { requireWritable(key, RELEASE_WRITE); await requireWriter().file(key).write(bytes, { type }); }, async remove(key) { requireWritable(key, RELEASE_FILE); await requireWriter().file(key).delete(); }, }; } function folderReader(root: string): Bucket { const list = async (prefix: string) => { const entries = await readdir(root, { recursive: true, withFileTypes: true }); return entries .filter((entry) => entry.isFile()) .map((entry) => relative(root, join(entry.parentPath, entry.name))) .filter((key) => key.startsWith(prefix)) .sort(); }; return { async get(key) { if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`); const file = Bun.file(join(root, key)); return (await file.exists()) ? await file.bytes() : null; }, list, async sizes(prefix) { return new Map((await list(prefix)).map((key) => [key, Bun.file(join(root, key)).size])); }, // Development and tests only: a file:// URL can't carry a filename, a type or an expiry. downloadUrl(key) { if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`); return pathToFileURL(join(root, key)).href; }, viewUrl: () => null, async put(key, bytes) { requireWritable(key, RELEASE_WRITE); await Bun.write(join(root, key), bytes); }, async remove(key) { requireWritable(key, RELEASE_FILE); await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); }, }; }