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 read key cannot list the bucket: ${reason(error)}`;98 }99 if (!source.write) return null;100 try {101 const stamp = new TextEncoder().encode(new Date().toISOString());102 await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");103 } catch (error) {104 return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;105 }106 return null;107}108109function s3Client(110 source: S3Source,111 keys: { accessKeyId: string; secretAccessKey: string } = source,112): Bun.S3Client {113 return new Bun.S3Client({114 endpoint: source.endpoint,115 bucket: source.name,116 region: source.region,117 accessKeyId: keys.accessKeyId,118 secretAccessKey: keys.secretAccessKey,119 });120}121122function s3Reader(source: S3Source): Bucket {123 const client = s3Client(source);124 125 const writer = source.write && s3Client(source, source.write);126 const requireWriter = () => {127 if (!writer) {128 throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");129 }130 return writer;131 };132 return {133 async get(key) {134 135 136 const response = await fetch(client.presign(key, { method: "GET", expiresIn: 60 }));137 if (response.status === 404) return null;138 if (!response.ok) throw new Error(`S3 GET ${key} failed: HTTP ${response.status}`);139 return new Uint8Array(await response.arrayBuffer());140 },141 async list(prefix) {142 const keys: string[] = [];143 let continuationToken: string | undefined;144 do {145 const page = await client.list({ prefix, continuationToken });146 for (const object of page.contents ?? []) keys.push(object.key);147 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;148 } while (continuationToken);149 return keys;150 },151 size: (key) => client.size(key),152 async folders() {153 const names: string[] = [];154 let continuationToken: string | undefined;155 do {156 const page = await client.list({ delimiter: "/", continuationToken });157 for (const { prefix } of page.commonPrefixes ?? []) names.push(prefix.slice(0, -1));158 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;159 } while (continuationToken);160 return names;161 },162 async sizes(prefix) {163 const sizes = new Map<string, number>();164 let continuationToken: string | undefined;165 do {166 const page = await client.list({ prefix, continuationToken });167 for (const object of page.contents ?? []) sizes.set(object.key, object.size ?? 0);168 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;169 } while (continuationToken);170 return sizes;171 },172 downloadUrl(key, filename, type) {173 174 return client.presign(key, {175 method: "GET",176 expiresIn: DOWNLOAD_SECONDS,177 contentDisposition: `attachment; filename="${filename}"`,178 type,179 });180 },181 viewUrl(key, type) {182 return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });183 },184 async put(key, bytes, type) {185 requireWritable(key, RELEASE_WRITE);186 await requireWriter().file(key).write(bytes, { type });187 },188 async remove(key) {189 requireWritable(key, RELEASE_FILE);190 await requireWriter().file(key).delete();191 },192 async copy(from, to) {193 requireRepoKey(from);194 requireRepoKey(to);195 const bytes = await this.get(from);196 if (bytes === null) throw new Error(`${from} is missing`);197 await requireWriter().file(to).write(bytes);198 },199 async removeRenamed(key) {200 requireRepoKey(key);201 await requireWriter().file(key).delete();202 },203 };204}205206function folderReader(root: string): Bucket {207 const list = async (prefix: string) => {208 const entries = await readdir(root, { recursive: true, withFileTypes: true });209 return entries210 .filter((entry) => entry.isFile())211 .map((entry) => relative(root, join(entry.parentPath, entry.name)))212 .filter((key) => key.startsWith(prefix))213 .sort();214 };215 return {216 async get(key) {217 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);218 const file = Bun.file(join(root, key));219 return (await file.exists()) ? await file.bytes() : null;220 },221 list,222 async size(key) {223 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);224 return Bun.file(join(root, key)).size;225 },226 async folders() {227 const entries = await readdir(root, { withFileTypes: true });228 return entries229 .filter((entry) => entry.isDirectory())230 .map((entry) => entry.name)231 .sort();232 },233 async sizes(prefix) {234 return new Map((await list(prefix)).map((key) => [key, Bun.file(join(root, key)).size]));235 },236 237 downloadUrl(key) {238 if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);239 return pathToFileURL(join(root, key)).href;240 },241 viewUrl: () => null,242 async put(key, bytes) {243 requireWritable(key, RELEASE_WRITE);244 await Bun.write(join(root, key), bytes);245 },246 async remove(key) {247 requireWritable(key, RELEASE_FILE);248 await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {249 if (error.code !== "ENOENT") throw error;250 });251 },252 async copy(from, to) {253 requireRepoKey(from);254 requireRepoKey(to);255 await Bun.write(join(root, to), Bun.file(join(root, from)));256 },257 async removeRenamed(key) {258 requireRepoKey(key);259 await unlink(join(root, key));260 },261 };262}