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:
30 files changed
+5369
-6899
No files matched your search
@@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
export default dayjs
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface PrototypeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
entryFile: string;
|
||||
createdAt: string;
|
||||
viewUrl: string;
|
||||
}
|
||||
|
||||
export const THEME_STORAGE_KEY = "prototype-dark-mode";
|
||||
+331
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user