- 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
332 lines
8.5 KiB
TypeScript
332 lines
8.5 KiB
TypeScript
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;
|
||
}
|
||
}
|