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 folders(): Promise<string[]>;19 20 size(key: string): Promise<number>;21 22 sizes(prefix: string): Promise<Map<string, number>>;23 24 downloadUrl(key: string, filename: string, type: string): string;25 26 viewUrl(key: string, type: string): string | null;27 28293031 put(key: string, bytes: Uint8Array, type: string): Promise<void>;32 33 remove(key: string): Promise<void>;34 35 copy(from: string, to: string): Promise<void>;36 37 removeRenamed(key: string): Promise<void>;38};3940export const KEPR_PREFIX = ".kepr/";414243export async function inTurn<T, R>(44 items: T[],45 atOnce: number,46 each: (item: T) => Promise<R>,47): Promise<R[]> {48 const results: R[] = [];49 for (let i = 0; i < items.length; i += atOnce) {50 results.push(...(await Promise.all(items.slice(i, i + atOnce).map(each))));51 }52 return results;53}545556export class BucketWriteError extends Error {}5758const DOWNLOAD_SECONDS = 5 * 60;5960const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/;61const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;626364function requireRepoKey(key: string): void {65 if (66 key.startsWith(KEPR_PREFIX) ||67 !key.includes("/") ||68 key.split("/").some((p) => p === ".." || p === "")69 ) {70 throw new BucketWriteError(`a rename moves only a repo's keys, not "${key}"`);71 }72}7374function requireWritable(key: string, releaseKey: RegExp): void {75 const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);76 if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {77 throw new BucketWriteError(`Kepr writes only under ${KEPR_PREFIX} and builds, not "${key}"`);78 }79}8081export function openBucket(source: BucketSource): Bucket {82 return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);83}8485type S3Source = Extract<BucketSource, { kind: "s3" }>;868788899091export async function checkS3Access(source: S3Source): Promise<string | null> {92 const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));93 try {94 95 await s3Client(source).list({ maxKeys: 1 });96 } catch (error) {97 return `The key cannot list the bucket: ${reason(error)}`;98 }99 try {100 const stamp = new TextEncoder().encode(new Date().toISOString());101 await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");102 } catch (error) {103 return `The key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;104 }105 return null;106}107108function s3Client(source: S3Source): Bun.S3Client {109 return new Bun.S3Client({110 endpoint: source.endpoint,111 bucket: source.name,112 region: source.region,113 accessKeyId: source.accessKeyId,114 secretAccessKey: source.secretAccessKey,115 });116}117118function s3Reader(source: S3Source): Bucket {119 const client = s3Client(source);120 121 const write = async (key: string, run: (file: Bun.S3File) => Promise<unknown>) => {122 try {123 await run(client.file(key));124 } catch (error) {125 throw new BucketWriteError(`The bucket refused ${key}: ${error}`);126 }127 };128 return {129 async get(key) {130 131 132 const response = await fetch(client.presign(key, { method: "GET", expiresIn: 60 }));133 if (response.status === 404) return null;134 if (!response.ok) throw new Error(`S3 GET ${key} failed: HTTP ${response.status}`);135 return new Uint8Array(await response.arrayBuffer());136 },137 async list(prefix) {138 const keys: string[] = [];139 let continuationToken: string | undefined;140 do {141 const page = await client.list({ prefix, continuationToken });142 for (const object of page.contents ?? []) keys.push(object.key);143 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;144 } while (continuationToken);145 return keys;146 },147 size: (key) => client.size(key),148 async folders() {149 const names: string[] = [];150 let continuationToken: string | undefined;151 do {152 const page = await client.list({ delimiter: "/", continuationToken });153 for (const { prefix } of page.commonPrefixes ?? []) names.push(prefix.slice(0, -1));154 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;155 } while (continuationToken);156 return names;157 },158 async sizes(prefix) {159 const sizes = new Map<string, number>();160 let continuationToken: string | undefined;161 do {162 const page = await client.list({ prefix, continuationToken });163 for (const object of page.contents ?? []) sizes.set(object.key, object.size ?? 0);164 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;165 } while (continuationToken);166 return sizes;167 },168 downloadUrl(key, filename, type) {169 170 return client.presign(key, {171 method: "GET",172 expiresIn: DOWNLOAD_SECONDS,173 contentDisposition: `attachment; filename="${filename}"`,174 type,175 });176 },177 viewUrl(key, type) {178 return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });179 },180 async put(key, bytes, type) {181 requireWritable(key, RELEASE_WRITE);182 await write(key, (file) => file.write(bytes, { type }));183 },184 async remove(key) {185 requireWritable(key, RELEASE_FILE);186 await write(key, (file) => file.delete());187 },188 async copy(from, to) {189 requireRepoKey(from);190 requireRepoKey(to);191 const bytes = await this.get(from);192 if (bytes === null) throw new Error(`${from} is missing`);193 await write(to, (file) => file.write(bytes));194 },195 async removeRenamed(key) {196 requireRepoKey(key);197 await write(key, (file) => file.delete());198 },199 };200}201202function folderReader(root: string): Bucket {203 const list = async (prefix: string) => {204 const entries = await readdir(root, { recursive: true, withFileTypes: true });205 return entries206 .filter((entry) => entry.isFile())207 .map((entry) => relative(root, join(entry.parentPath, entry.name)))208 .filter((key) => key.startsWith(prefix))209 .sort();210 };211 return {212 async get(key) {213 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);214 const file = Bun.file(join(root, key));215 return (await file.exists()) ? await file.bytes() : null;216 },217 list,218 async size(key) {219 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);220 return Bun.file(join(root, key)).size;221 },222 async folders() {223 const entries = await readdir(root, { withFileTypes: true });224 return entries225 .filter((entry) => entry.isDirectory())226 .map((entry) => entry.name)227 .sort();228 },229 async sizes(prefix) {230 return new Map((await list(prefix)).map((key) => [key, Bun.file(join(root, key)).size]));231 },232 233 downloadUrl(key) {234 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);235 return pathToFileURL(join(root, key)).href;236 },237 viewUrl: () => null,238 async put(key, bytes) {239 requireWritable(key, RELEASE_WRITE);240 await Bun.write(join(root, key), bytes);241 },242 async remove(key) {243 requireWritable(key, RELEASE_FILE);244 await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {245 if (error.code !== "ENOENT") throw error;246 });247 },248 async copy(from, to) {249 requireRepoKey(from);250 requireRepoKey(to);251 await Bun.write(join(root, to), Bun.file(join(root, from)));252 },253 async removeRenamed(key) {254 requireRepoKey(key);255 await unlink(join(root, key));256 },257 };258}