Files
prototype-manager/lib/store.ts
T
siujamo 971a59a997 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
2026-08-31 14:08:08 +08:00

335 lines
8.4 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
}
}