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
This commit is contained in:
siujamo committed 2026-08-31 13:43:59 +08:00
1 parent 0c592225e4
commit ed72045f5e
30 files changed
+5369 -6899

No files matched your search

+331
View File
@@ -0,0 +1,331 @@
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;
}
}