1.1.1版本审核修复
[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。 跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。 [spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。 自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。 已删除后台整组补刷进程及 relatedDataPaths。 [viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。 当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。 手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。 八种语言文案及 README 已同步。 处理历史日志遗留问题
This commit is contained in:
1 parent
473a270510
commit
bb73cc9227
36 files changed
+499
-652
No files matched your search
@@ -1,85 +0,0 @@
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { spawn } = require('child_process');
|
||||
const utils = require('./spine-utils');
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
function enqueue(src) {
|
||||
const library = utils.findLibraryRoot(src);
|
||||
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
|
||||
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
|
||||
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
|
||||
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
||||
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
|
||||
fs.renameSync(temp, job);
|
||||
// Electron's Node mode runs the local worker without opening another Eagle.
|
||||
const child = spawn(process.execPath, [__filename, directory], {
|
||||
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||
});
|
||||
child.on('error', error => {
|
||||
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function run(directory) {
|
||||
const lock = path.join(directory, 'worker.lock');
|
||||
let fd;
|
||||
try { fd = fs.openSync(lock, 'wx'); }
|
||||
catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
let pid;
|
||||
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
|
||||
if (!pid) return; // Another worker is still writing its lock.
|
||||
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
|
||||
try { fs.unlinkSync(lock); } catch (_) { return; }
|
||||
return run(directory);
|
||||
}
|
||||
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
|
||||
const snapshots = new Map();
|
||||
const nextChecks = new Map();
|
||||
const thumbnail = require('../thumbnail/spine');
|
||||
try {
|
||||
let idle = 0;
|
||||
while (idle < 2) {
|
||||
await sleep(1000); // Allow the importing callback/window to finish.
|
||||
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
|
||||
if (!jobs.length) { idle++; continue; }
|
||||
idle = 0;
|
||||
for (const name of jobs) {
|
||||
const file = path.join(directory, name);
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(file, 'utf8');
|
||||
const job = JSON.parse(raw);
|
||||
if (Date.now() < (nextChecks.get(name) || 0)) continue;
|
||||
const remaining = job.deadline - Date.now();
|
||||
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
|
||||
const result = Date.now() > job.deadline ? { complete: true }
|
||||
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
|
||||
snapshots.set(name, result.signature);
|
||||
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
|
||||
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.unlinkSync(lock);
|
||||
// Cover an enqueue racing the final idle check and lock release.
|
||||
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { enqueue, run };
|
||||
if (require.main === module) run(process.argv[2]).catch(error => {
|
||||
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+6
-6
@@ -59,7 +59,7 @@ function loadRuntime(dir) {
|
||||
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
|
||||
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
|
||||
*/
|
||||
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
|
||||
function buildTextureAtlas(spine, atlasText, texturePaths) {
|
||||
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
|
||||
return new Promise((resolve, reject) => {
|
||||
(async () => {
|
||||
@@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
|
||||
|
||||
const textureMap = {};
|
||||
await Promise.all(pageNames.map(async (name) => {
|
||||
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
|
||||
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
|
||||
if (!fs.existsSync(p)) return;
|
||||
// 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
|
||||
// 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
|
||||
const p = texturePaths && texturePaths[name];
|
||||
if (!p || !fs.existsSync(p)) return;
|
||||
try {
|
||||
const img = await canvasLib.loadImage(p);
|
||||
textureMap[name] = img;
|
||||
@@ -150,12 +151,11 @@ function getBounds(spine, skeleton) {
|
||||
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
|
||||
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
|
||||
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
|
||||
const dir = path.dirname(skeletonPath);
|
||||
const atlasText = fs.readFileSync(atlasPath, 'utf8');
|
||||
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
|
||||
|
||||
const spine = loadRuntime(runtimeDir);
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, texturePaths);
|
||||
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
|
||||
|
||||
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
|
||||
|
||||
+100
-29
@@ -22,6 +22,80 @@ const JSON_PROBE_BYTES = 256 * 1024;
|
||||
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i;
|
||||
|
||||
function unsafeAtlasPath(name, reason) {
|
||||
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
|
||||
error.code = 'UNSAFE_ATLAS_PATH';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
|
||||
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
|
||||
*/
|
||||
function validateAtlasPageName(name) {
|
||||
const raw = String(name == null ? '' : name).trim();
|
||||
if (!raw) throw unsafeAtlasPath(name, '路径为空');
|
||||
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
|
||||
const portable = raw.replace(/\\/g, '/');
|
||||
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
|
||||
throw unsafeAtlasPath(raw, '不允许绝对路径');
|
||||
}
|
||||
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
|
||||
const parts = portable.split('/');
|
||||
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
|
||||
const normalized = path.posix.normalize(portable);
|
||||
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
||||
throw unsafeAtlasPath(raw, '路径无效');
|
||||
}
|
||||
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
|
||||
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
|
||||
}
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function isPathInside(basePath, targetPath) {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
||||
}
|
||||
|
||||
function realpath(filePath) {
|
||||
const implementation = fs.realpathSync.native || fs.realpathSync;
|
||||
return implementation(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
|
||||
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
|
||||
*/
|
||||
function resolveFileWithin(baseDir, relativeName, atlasPage) {
|
||||
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
|
||||
const base = path.resolve(baseDir);
|
||||
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
|
||||
if (!isPathInside(base, candidate)) {
|
||||
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
|
||||
return null;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) return null;
|
||||
let realBase;
|
||||
let realCandidate;
|
||||
try {
|
||||
realBase = realpath(base);
|
||||
realCandidate = realpath(candidate);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInside(realBase, realCandidate)) {
|
||||
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
|
||||
return null;
|
||||
}
|
||||
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function resolveAtlasTextureFile(atlasDir, pageName) {
|
||||
return resolveFileWithin(atlasDir, pageName, true);
|
||||
}
|
||||
|
||||
function extOf(name) {
|
||||
const lower = name.toLowerCase();
|
||||
for (let i = 0; i < SKELETON_EXTS.length; i++) {
|
||||
@@ -335,12 +409,24 @@ function collectSpineFiles(roots, validFolders) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const root of roots) {
|
||||
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
|
||||
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
|
||||
const probe = path.join(root, '__spineviewer_probe__');
|
||||
const libRoot = findLibraryRoot(probe);
|
||||
if (libRoot) {
|
||||
try {
|
||||
const imagesReal = realpath(path.join(libRoot, 'images'));
|
||||
const rootReal = realpath(root);
|
||||
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
|
||||
} catch (e) { continue; }
|
||||
}
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(root); } catch (e) { continue; }
|
||||
for (const name of entries) {
|
||||
const full = path.join(root, name);
|
||||
try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; }
|
||||
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
|
||||
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
|
||||
const full = resolveFileWithin(root, name, false);
|
||||
if (!full) continue;
|
||||
// 排除 Eagle 回收站中已删除的项
|
||||
if (isItemDeleted(full)) continue;
|
||||
if (validFolders && validFolders.size) {
|
||||
@@ -576,15 +662,16 @@ function discoverAssets(filePath, options) {
|
||||
const pageNames = parseAtlasPages(text);
|
||||
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
|
||||
for (const pn of pageNames) {
|
||||
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠)
|
||||
const inAtlasDir = path.join(atlasDir, pn);
|
||||
if (fs.existsSync(inAtlasDir)) {
|
||||
// 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
|
||||
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
|
||||
if (inAtlasDir) {
|
||||
texturePaths[pn] = inAtlasDir;
|
||||
continue;
|
||||
}
|
||||
// 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选
|
||||
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase());
|
||||
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn));
|
||||
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
|
||||
const pageFileName = path.posix.basename(pn);
|
||||
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
|
||||
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
|
||||
let hit = selectBest(cands, atlas.fullPath);
|
||||
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
|
||||
if (hit) texturePaths[pn] = hit.fullPath;
|
||||
@@ -594,22 +681,6 @@ function discoverAssets(filePath, options) {
|
||||
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
|
||||
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
|
||||
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
|
||||
const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null);
|
||||
const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0;
|
||||
const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0;
|
||||
const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) =>
|
||||
normalizeStem(f.name) === base &&
|
||||
familyOfSkel(f) === chosenFamily &&
|
||||
Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta
|
||||
).map((f) => f.fullPath) : [];
|
||||
const relatedAtlasPaths = atlas ? atlasFiles.filter((f) =>
|
||||
normalizeStem(f.name) === base &&
|
||||
familyOfAtlas(f) === chosenFamily &&
|
||||
Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta
|
||||
).map((f) => f.fullPath) : [];
|
||||
const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))]
|
||||
.filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp));
|
||||
|
||||
return {
|
||||
skeletonPath: skeleton ? skeleton.fullPath : null,
|
||||
atlasPath: atlas ? atlas.fullPath : null,
|
||||
@@ -621,7 +692,6 @@ function discoverAssets(filePath, options) {
|
||||
atlasCandidates: atlasFiles.map((f) => f.name),
|
||||
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
|
||||
textureCandidates: textureFiles.map((f) => f.name),
|
||||
relatedDataPaths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -633,15 +703,14 @@ function parseAtlasPages(atlasText) {
|
||||
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) {
|
||||
pages.push(line);
|
||||
if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
|
||||
pages.push(validateAtlasPageName(line));
|
||||
} else if (line === '') {
|
||||
// 空白行之后的下一个非空行可能是下一页文件名
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等)
|
||||
return pages.filter((p) => !/[=,:]/.test(p));
|
||||
return [...new Set(pages)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -694,6 +763,8 @@ module.exports = {
|
||||
looksLikeSkeletonJson,
|
||||
discoverAssets,
|
||||
parseAtlasPages,
|
||||
validateAtlasPageName,
|
||||
resolveAtlasTextureFile,
|
||||
parseAtlasPma,
|
||||
preferredSkin,
|
||||
applySkin,
|
||||
|
||||
Reference in new issue
Block a user