Files
prototype-manager/lib/store.ts
T
siujamo ed72045f5e feat(prototype): add upload manager and docker ci
- add prototype upload, list, view, and delete api routes
- add prototype manager ui with search, modal, and theme toggle
- add filesystem-backed zip extraction and serving logic
- switch project to pnpm with updated lockfile and tooling
- add multi-stage dockerfile with standalone next.js output
- add github actions to lint, build, and publish docker images
2026-08-31 13:43:59 +08:00

332 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AdmZip from "adm-zip";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { PrototypeSummary } from "@/lib/prototype";
interface PrototypeRecord {
id: string;
name: string;
description: string;
entryFile: string;
createdAt: string;
}
const DATA_ROOT = path.join(process.cwd(), "data", "prototypes");
const INDEX_FILE = path.join(DATA_ROOT, "index.json");
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
const MAX_FILES = 20_000;
const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024;
let writeQueue: Promise<void> = Promise.resolve();
function queueWrite<T>(work: () => Promise<T>): Promise<T> {
const next = writeQueue.then(work, work);
writeQueue = next.then(
() => undefined,
() => undefined,
);
return next;
}
async function ensureDataRoot() {
await fs.mkdir(DATA_ROOT, { recursive: true });
}
async function readIndex(): Promise<PrototypeRecord[]> {
await ensureDataRoot();
try {
const raw = await fs.readFile(INDEX_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((item): item is PrototypeRecord => {
return (
typeof item?.id === "string" &&
typeof item?.name === "string" &&
typeof item?.description === "string" &&
typeof item?.entryFile === "string" &&
typeof item?.createdAt === "string"
);
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
}
async function writeIndex(records: PrototypeRecord[]) {
await ensureDataRoot();
const tempFile = `${INDEX_FILE}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fs.writeFile(tempFile, JSON.stringify(records, null, 2), "utf-8");
await fs.rename(tempFile, INDEX_FILE);
}
function toSummary(record: PrototypeRecord): PrototypeSummary {
return {
...record,
viewUrl: `/api/prototypes/${encodeURIComponent(record.id)}/${encodePathSegments(record.entryFile)}`,
};
}
function generateId() {
return `proto-${Date.now()}-${randomUUID().slice(0, 8)}`;
}
function isPathInside(rootDir: string, candidatePath: string) {
const root = path.resolve(rootDir);
const candidate = path.resolve(candidatePath);
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
function sanitizeZipEntry(entryName: string) {
const normalized = entryName.replace(/\\/g, "/");
const cleaned = path.posix.normalize(normalized);
if (!cleaned || cleaned === ".") {
return null;
}
if (
cleaned.startsWith("../") ||
cleaned === ".." ||
cleaned.startsWith("/") ||
/^[A-Za-z]:/.test(cleaned)
) {
throw new Error(`ZIP 中包含非法路径:${entryName}`);
}
return cleaned;
}
function detectMimeType(filePath: string) {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case ".html":
case ".htm":
return "text/html; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".js":
return "application/javascript; charset=utf-8";
case ".json":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".ico":
return "image/x-icon";
case ".woff":
return "font/woff";
case ".woff2":
return "font/woff2";
case ".ttf":
return "font/ttf";
default:
return "application/octet-stream";
}
}
function encodePathSegments(relativePath: string) {
return relativePath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
export async function listPrototypes(): Promise<PrototypeSummary[]> {
const records = await readIndex();
return records
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map(toSummary);
}
export async function findPrototypeById(id: string): Promise<PrototypeSummary | null> {
const records = await readIndex();
const found = records.find((item) => item.id === id);
return found ? toSummary(found) : null;
}
export async function createPrototypeFromZip(input: {
name: string;
description?: string;
zipFileName: string;
zipBuffer: Buffer;
}): Promise<PrototypeSummary> {
return queueWrite(async () => {
const name = input.name.trim();
if (!name) {
throw new Error("原型名称不能为空");
}
if (!input.zipFileName.toLowerCase().endsWith(".zip")) {
throw new Error("仅支持上传 ZIP 文件");
}
if (input.zipBuffer.byteLength > MAX_UPLOAD_BYTES) {
throw new Error("上传文件过大,请控制在 200MB 以内");
}
const zip = new AdmZip(input.zipBuffer);
const entries = zip.getEntries();
if (!entries.length) {
throw new Error("ZIP 文件为空");
}
if (entries.length > MAX_FILES) {
throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`);
}
await ensureDataRoot();
const id = generateId();
const tempDir = path.join(DATA_ROOT, `.tmp-${id}`);
const finalDir = path.join(DATA_ROOT, id);
const extractedFiles: string[] = [];
let totalUncompressed = 0;
await fs.mkdir(tempDir, { recursive: true });
try {
for (const entry of entries) {
const safePath = sanitizeZipEntry(entry.entryName);
if (!safePath) {
continue;
}
const target = path.join(tempDir, ...safePath.split("/"));
if (!isPathInside(tempDir, target)) {
throw new Error(`ZIP 中包含非法路径:${entry.entryName}`);
}
if (entry.isDirectory) {
await fs.mkdir(target, { recursive: true });
continue;
}
totalUncompressed += entry.header.size;
if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
throw new Error("解压后文件总体积过大");
}
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, entry.getData());
extractedFiles.push(safePath);
}
const startCandidates = extractedFiles
.filter((item) => item.toLowerCase().endsWith("/start.html") || item.toLowerCase() === "start.html")
.sort((a, b) => a.length - b.length);
if (!startCandidates.length) {
throw new Error("ZIP 中未找到 start.html");
}
const entryFile = startCandidates[0];
await fs.rm(finalDir, { recursive: true, force: true });
await fs.rename(tempDir, finalDir);
const records = await readIndex();
const record: PrototypeRecord = {
id,
name,
description: input.description?.trim() ?? "",
createdAt: new Date().toISOString(),
entryFile,
};
records.push(record);
await writeIndex(records);
return toSummary(record);
} catch (error) {
await fs.rm(tempDir, { recursive: true, force: true });
throw error;
}
});
}
export async function deletePrototype(id: string): Promise<boolean> {
return queueWrite(async () => {
const records = await readIndex();
const target = records.find((item) => item.id === id);
if (!target) {
return false;
}
const remained = records.filter((item) => item.id !== id);
await writeIndex(remained);
const targetDir = path.join(DATA_ROOT, id);
if (isPathInside(DATA_ROOT, targetDir)) {
await fs.rm(targetDir, { recursive: true, force: true });
}
return true;
});
}
export async function readPrototypeFile(id: string, relativePath?: string) {
const records = await readIndex();
const target = records.find((item) => item.id === id);
if (!target) {
return null;
}
const requested = relativePath?.trim() ? relativePath : target.entryFile;
const normalized = sanitizeZipEntry(requested);
if (!normalized) {
return null;
}
const filePath = path.join(DATA_ROOT, id, ...normalized.split("/"));
if (!isPathInside(path.join(DATA_ROOT, id), filePath)) {
return null;
}
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return null;
}
const content = await fs.readFile(filePath);
return {
content,
mimeType: detectMimeType(filePath),
size: stat.size,
lastModified: stat.mtime.toUTCString(),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}