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:
tianmo committed 2026-09-17 13:38:22 +08:00
1 parent 473a270510
commit bb73cc9227
36 files changed
+499 -652

No files matched your search

+100 -29
View File
@@ -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,