import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, normalize, sep } from 'node:path' import { prepareSpine38SkeletonJson } from './spine38-project.mjs' const MAX_JSON_BYTES = 64 * 1024 * 1024 const MAX_IMAGE_BYTES = 32 * 1024 * 1024 const MAX_TOTAL_IMAGE_BYTES = 128 * 1024 * 1024 export function safeProjectName(value) { const name = String(value || 'SpineParticle').trim().replace(/[\\/:*?"<>|\x00-\x1f]+/g, '_').replace(/^\.+/, '') return name.slice(0, 100) || 'SpineParticle' } function safeImagePath(value) { const raw = String(value || '').replace(/\\/g, '/') if (!raw || raw.startsWith('/') || raw.includes('\0')) throw new Error('图片路径无效') const path = normalize(raw) if (path === '..' || path.startsWith(`..${sep}`)) throw new Error('图片路径不能超出 images 目录') return path } function safeImagesDirectory(value) { const raw = String(value || './images/').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '') || 'images' if (raw.startsWith('/') || /^[a-z]+:/i.test(raw)) throw new Error('Spine 图片路径必须是工程内的相对路径') return safeImagePath(raw) } function decodeBase64(value) { if (typeof value !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('图片数据格式无效') const data = Buffer.from(value, 'base64') if (data.length > MAX_IMAGE_BYTES) throw new Error('单张图片不能超过 32MB') return data } export async function createConversionWorkspace(payload) { if (!payload || payload.schemaVersion !== 1) throw new Error('不支持的连接器请求格式') if (typeof payload.skeletonJson !== 'string' || Buffer.byteLength(payload.skeletonJson) > MAX_JSON_BYTES) throw new Error('Spine JSON 缺失或超过 64MB') let skeleton try { skeleton = JSON.parse(payload.skeletonJson) } catch { throw new Error('Spine JSON 格式无效') } const jsonVersion = String(skeleton?.skeleton?.spine || '') const spineVersion = /^(3\.8|4\.[0-3])(?:\.\d+)?$/.exec(jsonVersion)?.[1] if (!spineVersion) throw new Error(`JSON 中的 Spine 版本不受支持:${jsonVersion || '未知'}`) const requestedVersion = String(payload.spineVersion || spineVersion) if (requestedVersion !== spineVersion) throw new Error(`请求版本 ${requestedVersion} 与 JSON 版本 ${jsonVersion} 不一致`) const imagesDirectory = safeImagesDirectory(skeleton?.skeleton?.images) const root = await mkdtemp(join(tmpdir(), 'spine-particle-')) try { const projectName = safeProjectName(payload.projectName) const inputPath = join(root, `${projectName}.json`) const outputPath = join(root, `${projectName}.spine`) const importJson = spineVersion === '3.8' ? prepareSpine38SkeletonJson(skeleton, imagesDirectory) : payload.skeletonJson await mkdir(join(root, imagesDirectory), { recursive: true }) await writeFile(inputPath, importJson, 'utf8') let total = 0 const archiveImages = [] for (const image of Array.isArray(payload.images) ? payload.images : []) { const data = decodeBase64(image?.dataBase64) total += data.length if (total > MAX_TOTAL_IMAGE_BYTES) throw new Error('图片总大小不能超过 128MB') const relativeImagePath = safeImagePath(image?.path) const target = join(root, imagesDirectory, relativeImagePath) await mkdir(dirname(target), { recursive: true }) await writeFile(target, data) archiveImages.push({ name: `${imagesDirectory.replace(/\\/g, '/')}/${relativeImagePath.replace(/\\/g, '/')}`, data }) } return { root, projectName, spineVersion, archiveImages, inputPath, outputPath, readOutput: () => readFile(outputPath), cleanup: () => rm(root, { recursive: true, force: true }), } } catch (error) { await rm(root, { recursive: true, force: true }) throw error } }