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 22 put(key: string, bytes: Uint8Array, type: string): Promise<void>;23 24 remove(key: string): Promise<void>;25};2627export const KEPR_PREFIX = ".kepr/";282930export class BucketWriteError extends Error {}3132const DOWNLOAD_SECONDS = 5 * 60;3334const RELEASES_LATEST = /^[^/]+\/releases\/latest$/;35const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;3637function requireWritable(key: string, releaseKey: RegExp): void {38 const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);39 if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {40 throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`);41 }42}4344export function openBucket(source: BucketSource): Bucket {45 return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);46}4748type S3Source = Extract<BucketSource, { kind: "s3" }>;495051525354export async function checkS3Access(source: S3Source): Promise<string | null> {55 const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));56 try {57 58 await s3Client(source).list({ maxKeys: 1 });59 } catch (error) {60 return `The read key cannot list the bucket: ${reason(error)}`;61 }62 if (!source.write) return null;63 try {64 const stamp = new TextEncoder().encode(new Date().toISOString());65 await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");66 } catch (error) {67 return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;68 }69 return null;70}7172function s3Client(73 source: S3Source,74 keys: { accessKeyId: string; secretAccessKey: string } = source,75): Bun.S3Client {76 return new Bun.S3Client({77 endpoint: source.endpoint,78 bucket: source.name,79 region: source.region,80 accessKeyId: keys.accessKeyId,81 secretAccessKey: keys.secretAccessKey,82 });83}8485function s3Reader(source: S3Source): Bucket {86 const client = s3Client(source);87 88 const writer = source.write && s3Client(source, source.write);89 const requireWriter = () => {90 if (!writer) {91 throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");92 }93 return writer;94 };95 return {96 async get(key) {97 try {98 return await client.file(key).bytes();99 } catch (error) {100 if ((error as { code?: string }).code === "NoSuchKey") return null;101 throw error;102 }103 },104 async list(prefix) {105 const keys: string[] = [];106 let continuationToken: string | undefined;107 do {108 const page = await client.list({ prefix, continuationToken });109 for (const object of page.contents ?? []) keys.push(object.key);110 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;111 } while (continuationToken);112 return keys;113 },114 downloadUrl(key, filename, type) {115 116 return client.presign(key, {117 method: "GET",118 expiresIn: DOWNLOAD_SECONDS,119 contentDisposition: `attachment; filename="${filename}"`,120 type,121 });122 },123 viewUrl(key, type) {124 return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });125 },126 async put(key, bytes, type) {127 requireWritable(key, RELEASES_LATEST);128 await requireWriter().file(key).write(bytes, { type });129 },130 async remove(key) {131 requireWritable(key, RELEASE_FILE);132 await requireWriter().file(key).delete();133 },134 };135}136137function folderReader(root: string): Bucket {138 return {139 async get(key) {140 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);141 const file = Bun.file(join(root, key));142 return (await file.exists()) ? await file.bytes() : null;143 },144 async list(prefix) {145 const entries = await readdir(root, { recursive: true, withFileTypes: true });146 return entries147 .filter((entry) => entry.isFile())148 .map((entry) => relative(root, join(entry.parentPath, entry.name)))149 .filter((key) => key.startsWith(prefix))150 .sort();151 },152 153 downloadUrl(key) {154 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);155 return pathToFileURL(join(root, key)).href;156 },157 viewUrl: () => null,158 async put(key, bytes) {159 requireWritable(key, RELEASES_LATEST);160 await Bun.write(join(root, key), bytes);161 },162 async remove(key) {163 requireWritable(key, RELEASE_FILE);164 await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {165 if (error.code !== "ENOENT") throw error;166 });167 },168 };169}