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 sizes(prefix: string): Promise<Map<string, number>>;19 20 downloadUrl(key: string, filename: string, type: string): string;21 22 viewUrl(key: string, type: string): string | null;23 24252627 put(key: string, bytes: Uint8Array, type: string): Promise<void>;28 29 remove(key: string): Promise<void>;30};3132export const KEPR_PREFIX = ".kepr/";333435export 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" }>;545556575859export async function checkS3Access(source: S3Source): Promise<string | null> {60 const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));61 try {62 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 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 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 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}