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";567891011121314export type Bucket = {15 get(key: string): Promise<Uint8Array | null>;16 list(prefix: string): Promise<string[]>;17 18 downloadUrl(key: string, filename: string, type: string): string;19 20 viewUrl(key: string, type: string): string | null;21 22232425 put(key: string, bytes: Uint8Array, type: string): Promise<void>;26 27 remove(key: string): Promise<void>;28};2930export const KEPR_PREFIX = ".kepr/";313233export 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" }>;525354555657export async function checkS3Access(source: S3Source): Promise<string | null> {58 const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));59 try {60 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 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 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 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}