1import { readdir } from "node:fs/promises";2import { join, relative } from "node:path";3import { pathToFileURL } from "node:url";4import type { BucketSource } from "../server/config.ts";56789101112export type Bucket = {13 get(key: string): Promise<Uint8Array | null>;14 list(prefix: string): Promise<string[]>;15 16 downloadUrl(key: string, filename: string, type: string): string;17 18 viewUrl(key: string, type: string): string | null;19 20 put(key: string, bytes: Uint8Array, type: string): Promise<void>;21};2223export const KEPR_PREFIX = ".kepr/";242526export class BucketWriteError extends Error {}2728const DOWNLOAD_SECONDS = 5 * 60;2930function requireOwnKey(key: string): void {31 if (!key.startsWith(KEPR_PREFIX) || key.split("/").some((part) => part === ".." || part === "")) {32 throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX}, not "${key}"`);33 }34}3536export function openBucket(source: BucketSource): Bucket {37 return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);38}3940type S3Source = Extract<BucketSource, { kind: "s3" }>;414243444546export async function checkS3Access(source: S3Source): Promise<string | null> {47 const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));48 try {49 50 await s3Client(source).list({ maxKeys: 1 });51 } catch (error) {52 return `The read key cannot list the bucket: ${reason(error)}`;53 }54 if (!source.write) return null;55 try {56 const stamp = new TextEncoder().encode(new Date().toISOString());57 await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");58 } catch (error) {59 return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;60 }61 return null;62}6364function s3Client(65 source: S3Source,66 keys: { accessKeyId: string; secretAccessKey: string } = source,67): Bun.S3Client {68 return new Bun.S3Client({69 endpoint: source.endpoint,70 bucket: source.name,71 region: source.region,72 accessKeyId: keys.accessKeyId,73 secretAccessKey: keys.secretAccessKey,74 });75}7677function s3Reader(source: S3Source): Bucket {78 const client = s3Client(source);79 80 const writer = source.write && s3Client(source, source.write);81 return {82 async get(key) {83 try {84 return await client.file(key).bytes();85 } catch (error) {86 if ((error as { code?: string }).code === "NoSuchKey") return null;87 throw error;88 }89 },90 async list(prefix) {91 const keys: string[] = [];92 let continuationToken: string | undefined;93 do {94 const page = await client.list({ prefix, continuationToken });95 for (const object of page.contents ?? []) keys.push(object.key);96 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;97 } while (continuationToken);98 return keys;99 },100 downloadUrl(key, filename, type) {101 102 return client.presign(key, {103 method: "GET",104 expiresIn: DOWNLOAD_SECONDS,105 contentDisposition: `attachment; filename="${filename}"`,106 type,107 });108 },109 viewUrl(key, type) {110 return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });111 },112 async put(key, bytes, type) {113 requireOwnKey(key);114 if (!writer) {115 throw new BucketWriteError("Uploads need the bucket's write key: add [bucket.write].");116 }117 await writer.file(key).write(bytes, { type });118 },119 };120}121122function folderReader(root: string): Bucket {123 return {124 async get(key) {125 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);126 const file = Bun.file(join(root, key));127 return (await file.exists()) ? await file.bytes() : null;128 },129 async list(prefix) {130 const entries = await readdir(root, { recursive: true, withFileTypes: true });131 return entries132 .filter((entry) => entry.isFile())133 .map((entry) => relative(root, join(entry.parentPath, entry.name)))134 .filter((key) => key.startsWith(prefix))135 .sort();136 },137 138 downloadUrl(key) {139 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);140 return pathToFileURL(join(root, key)).href;141 },142 viewUrl: () => null,143 async put(key, bytes) {144 requireOwnKey(key);145 await Bun.write(join(root, key), bytes);146 },147 };148}