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