import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { getRuntimeEnv } from "./runtime-env"; type PutOptions = { httpMetadata?: { contentType?: string }; customMetadata?: Record; }; type StoredMetadata = { contentType: string; customMetadata: Record; uploadedAt: string; }; export class StoredObjectBody { readonly body: Uint8Array; private readonly bytes: Buffer; private readonly metadata: StoredMetadata; constructor(bytes: Buffer, metadata: StoredMetadata) { this.bytes = bytes; this.metadata = metadata; this.body = new Uint8Array(bytes); } async arrayBuffer() { return this.body.buffer.slice( this.body.byteOffset, this.body.byteOffset + this.body.byteLength, ) as ArrayBuffer; } writeHttpMetadata(headers: Headers) { headers.set("Content-Type", this.metadata.contentType); } get customMetadata() { return this.metadata.customMetadata; } } export function normalizeObjectKey(key: string) { const normalized = key.replaceAll("\\", "/").replace(/^\/+/, ""); if (!normalized || normalized.includes("\0")) { throw new Error("文件存储键无效"); } const parts = normalized.split("/"); if (parts.some((part) => !part || part === "." || part === "..")) { throw new Error("文件存储键不安全"); } return parts.join("/"); } function defaultUploadDir() { return path.join(process.cwd(), ".data", "uploads"); } export class LocalObjectStore { readonly root: string; constructor(root = getRuntimeEnv().UPLOAD_DIR || defaultUploadDir()) { this.root = root; } private resolve(key: string) { const normalized = normalizeObjectKey(key); const filePath = path.join(this.root, ...normalized.split("/")); return { normalized, filePath, metadataPath: `${filePath}.metadata.json` }; } async put(key: string, input: ArrayBuffer | Uint8Array, options: PutOptions = {}) { const target = this.resolve(key); await mkdir(path.dirname(target.filePath), { recursive: true }); const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); const metadata: StoredMetadata = { contentType: options.httpMetadata?.contentType || "application/octet-stream", customMetadata: options.customMetadata ?? {}, uploadedAt: new Date().toISOString(), }; const suffix = `${process.pid}-${crypto.randomUUID()}`; const temporaryFile = `${target.filePath}.${suffix}.tmp`; const temporaryMetadata = `${target.metadataPath}.${suffix}.tmp`; await Promise.all([ writeFile(temporaryFile, bytes), writeFile(temporaryMetadata, JSON.stringify(metadata), "utf8"), ]); await rename(temporaryFile, target.filePath); await rename(temporaryMetadata, target.metadataPath); return { key: target.normalized }; } async get(key: string) { const target = this.resolve(key); try { const [bytes, metadataText] = await Promise.all([ readFile(target.filePath), readFile(target.metadataPath, "utf8").catch(() => ""), ]); const fallback: StoredMetadata = { contentType: "application/octet-stream", customMetadata: {}, uploadedAt: (await stat(target.filePath)).mtime.toISOString(), }; const metadata = metadataText ? ({ ...fallback, ...JSON.parse(metadataText) } as StoredMetadata) : fallback; return new StoredObjectBody(bytes, metadata); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } } } declare global { var __kocLoopObjectStore: LocalObjectStore | undefined; } export function getObjectStore() { if (!globalThis.__kocLoopObjectStore) { globalThis.__kocLoopObjectStore = new LocalObjectStore(); } return globalThis.__kocLoopObjectStore; }