From 971a59a99779774956418fa7514a458e18c0f2fa Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 31 Aug 2026 14:08:08 +0800 Subject: [PATCH] fix(docker): include pnpm workspace in deps stage - copy pnpm-workspace.yaml in docker deps stage - fix pnpm frozen install failure in buildx workflow - keep allowed build scripts available during image build - apply repository formatting updates in store module --- Dockerfile | 2 +- lib/store.ts | 265 ++++++++++++++++++++++++++------------------------- 2 files changed, 135 insertions(+), 132 deletions(-) diff --git a/Dockerfile b/Dockerfile index ffa431d..921a04a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN corepack enable FROM base AS deps WORKDIR /app -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ pnpm install --frozen-lockfile diff --git a/lib/store.ts b/lib/store.ts index 226f4b2..bba2011 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -1,47 +1,47 @@ -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"; +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; + 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 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; +const MAX_UPLOAD_BYTES = 200 * 1024 * 1024 +const MAX_FILES = 20_000 +const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024 -let writeQueue: Promise = Promise.resolve(); +let writeQueue: Promise = Promise.resolve() function queueWrite(work: () => Promise): Promise { - const next = writeQueue.then(work, work); + const next = writeQueue.then(work, work) writeQueue = next.then( () => undefined, - () => undefined, - ); - return next; + () => undefined + ) + return next } async function ensureDataRoot() { - await fs.mkdir(DATA_ROOT, { recursive: true }); + await fs.mkdir(DATA_ROOT, { recursive: true }) } async function readIndex(): Promise { - await ensureDataRoot(); + await ensureDataRoot() try { - const raw = await fs.readFile(INDEX_FILE, "utf-8"); - const parsed = JSON.parse(raw); + const raw = await fs.readFile(INDEX_FILE, "utf-8") + const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) { - return []; + return [] } return parsed.filter((item): item is PrototypeRecord => { @@ -51,47 +51,47 @@ async function readIndex(): Promise { typeof item?.description === "string" && typeof item?.entryFile === "string" && typeof item?.createdAt === "string" - ); - }); + ) + }) } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return []; + return [] } - throw error; + 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); + 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)}`; + 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}`); + 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); + const normalized = entryName.replace(/\\/g, "/") + const cleaned = path.posix.normalize(normalized) if (!cleaned || cleaned === ".") { - return null; + return null } if ( @@ -100,46 +100,46 @@ function sanitizeZipEntry(entryName: string) { cleaned.startsWith("/") || /^[A-Za-z]:/.test(cleaned) ) { - throw new Error(`ZIP 中包含非法路径:${entryName}`); + throw new Error(`ZIP 中包含非法路径:${entryName}`) } - return cleaned; + return cleaned } function detectMimeType(filePath: string) { - const ext = path.extname(filePath).toLowerCase(); + const ext = path.extname(filePath).toLowerCase() switch (ext) { case ".html": case ".htm": - return "text/html; charset=utf-8"; + return "text/html; charset=utf-8" case ".css": - return "text/css; charset=utf-8"; + return "text/css; charset=utf-8" case ".js": - return "application/javascript; charset=utf-8"; + return "application/javascript; charset=utf-8" case ".json": - return "application/json; charset=utf-8"; + return "application/json; charset=utf-8" case ".svg": - return "image/svg+xml"; + return "image/svg+xml" case ".png": - return "image/png"; + return "image/png" case ".jpg": case ".jpeg": - return "image/jpeg"; + return "image/jpeg" case ".gif": - return "image/gif"; + return "image/gif" case ".webp": - return "image/webp"; + return "image/webp" case ".ico": - return "image/x-icon"; + return "image/x-icon" case ".woff": - return "font/woff"; + return "font/woff" case ".woff2": - return "font/woff2"; + return "font/woff2" case ".ttf": - return "font/ttf"; + return "font/ttf" default: - return "application/octet-stream"; + return "application/octet-stream" } } @@ -147,185 +147,188 @@ function encodePathSegments(relativePath: string) { return relativePath .split("/") .map((segment) => encodeURIComponent(segment)) - .join("/"); + .join("/") } export async function listPrototypes(): Promise { - const records = await readIndex(); + const records = await readIndex() return records .slice() .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .map(toSummary); + .map(toSummary) } export async function findPrototypeById(id: string): Promise { - const records = await readIndex(); - const found = records.find((item) => item.id === id); - return found ? toSummary(found) : 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; + name: string + description?: string + zipFileName: string + zipBuffer: Buffer }): Promise { return queueWrite(async () => { - const name = input.name.trim(); + const name = input.name.trim() if (!name) { - throw new Error("原型名称不能为空"); + throw new Error("原型名称不能为空") } if (!input.zipFileName.toLowerCase().endsWith(".zip")) { - throw new Error("仅支持上传 ZIP 文件"); + throw new Error("仅支持上传 ZIP 文件") } if (input.zipBuffer.byteLength > MAX_UPLOAD_BYTES) { - throw new Error("上传文件过大,请控制在 200MB 以内"); + throw new Error("上传文件过大,请控制在 200MB 以内") } - const zip = new AdmZip(input.zipBuffer); - const entries = zip.getEntries(); + const zip = new AdmZip(input.zipBuffer) + const entries = zip.getEntries() if (!entries.length) { - throw new Error("ZIP 文件为空"); + throw new Error("ZIP 文件为空") } if (entries.length > MAX_FILES) { - throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`); + throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`) } - await ensureDataRoot(); + await ensureDataRoot() - const id = generateId(); - const tempDir = path.join(DATA_ROOT, `.tmp-${id}`); - const finalDir = path.join(DATA_ROOT, id); + const id = generateId() + const tempDir = path.join(DATA_ROOT, `.tmp-${id}`) + const finalDir = path.join(DATA_ROOT, id) - const extractedFiles: string[] = []; - let totalUncompressed = 0; + const extractedFiles: string[] = [] + let totalUncompressed = 0 - await fs.mkdir(tempDir, { recursive: true }); + await fs.mkdir(tempDir, { recursive: true }) try { for (const entry of entries) { - const safePath = sanitizeZipEntry(entry.entryName); + const safePath = sanitizeZipEntry(entry.entryName) if (!safePath) { - continue; + continue } - const target = path.join(tempDir, ...safePath.split("/")); + const target = path.join(tempDir, ...safePath.split("/")) if (!isPathInside(tempDir, target)) { - throw new Error(`ZIP 中包含非法路径:${entry.entryName}`); + throw new Error(`ZIP 中包含非法路径:${entry.entryName}`) } if (entry.isDirectory) { - await fs.mkdir(target, { recursive: true }); - continue; + await fs.mkdir(target, { recursive: true }) + continue } - totalUncompressed += entry.header.size; + totalUncompressed += entry.header.size if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) { - throw new Error("解压后文件总体积过大"); + throw new Error("解压后文件总体积过大") } - await fs.mkdir(path.dirname(target), { recursive: true }); - await fs.writeFile(target, entry.getData()); - extractedFiles.push(safePath); + 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); + .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"); + throw new Error("ZIP 中未找到 start.html") } - const entryFile = startCandidates[0]; + const entryFile = startCandidates[0] - await fs.rm(finalDir, { recursive: true, force: true }); - await fs.rename(tempDir, finalDir); + await fs.rm(finalDir, { recursive: true, force: true }) + await fs.rename(tempDir, finalDir) - const records = await readIndex(); + const records = await readIndex() const record: PrototypeRecord = { id, name, description: input.description?.trim() ?? "", createdAt: new Date().toISOString(), entryFile, - }; + } - records.push(record); - await writeIndex(records); + records.push(record) + await writeIndex(records) - return toSummary(record); + return toSummary(record) } catch (error) { - await fs.rm(tempDir, { recursive: true, force: true }); - throw error; + await fs.rm(tempDir, { recursive: true, force: true }) + throw error } - }); + }) } export async function deletePrototype(id: string): Promise { return queueWrite(async () => { - const records = await readIndex(); - const target = records.find((item) => item.id === id); + const records = await readIndex() + const target = records.find((item) => item.id === id) if (!target) { - return false; + return false } - const remained = records.filter((item) => item.id !== id); - await writeIndex(remained); + const remained = records.filter((item) => item.id !== id) + await writeIndex(remained) - const targetDir = path.join(DATA_ROOT, id); + const targetDir = path.join(DATA_ROOT, id) if (isPathInside(DATA_ROOT, targetDir)) { - await fs.rm(targetDir, { recursive: true, force: true }); + await fs.rm(targetDir, { recursive: true, force: true }) } - return true; - }); + return true + }) } export async function readPrototypeFile(id: string, relativePath?: string) { - const records = await readIndex(); - const target = records.find((item) => item.id === id); + const records = await readIndex() + const target = records.find((item) => item.id === id) if (!target) { - return null; + return null } - const requested = relativePath?.trim() ? relativePath : target.entryFile; - const normalized = sanitizeZipEntry(requested); + const requested = relativePath?.trim() ? relativePath : target.entryFile + const normalized = sanitizeZipEntry(requested) if (!normalized) { - return null; + return null } - const filePath = path.join(DATA_ROOT, id, ...normalized.split("/")); + const filePath = path.join(DATA_ROOT, id, ...normalized.split("/")) if (!isPathInside(path.join(DATA_ROOT, id), filePath)) { - return null; + return null } try { - const stat = await fs.stat(filePath); + const stat = await fs.stat(filePath) if (!stat.isFile()) { - return null; + return null } - const content = await fs.readFile(filePath); + 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; + return null } - throw error; + throw error } }