126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
|
|
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<string, string>;
|
||
|
|
};
|
||
|
|
|
||
|
|
type StoredMetadata = {
|
||
|
|
contentType: string;
|
||
|
|
customMetadata: Record<string, string>;
|
||
|
|
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;
|
||
|
|
}
|