feat(sandbox): 不可信字节在沙箱子进程里解析(撤销权限、超时与内存强杀);种子、文件预览、字幕解析迁入
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1a76d2d700
commit
eeec91e892
21 files changed
+632
-178
No files matched your search
@@ -14,32 +14,22 @@ import type {
|
||||
import type { SubtitleWriteTable } from "@umt/db";
|
||||
import {
|
||||
type DefaultSubtitleLang,
|
||||
decodeSubtitle,
|
||||
detectSubtitleLanguage,
|
||||
isSidecarOf,
|
||||
pairSubtitle,
|
||||
type SubtitleExt,
|
||||
subtitleEpisode,
|
||||
subtitleExtOf,
|
||||
subtitleFileName,
|
||||
} from "@umt/domain";
|
||||
import { unzipSync } from "fflate";
|
||||
import type { Selectable } from "kysely";
|
||||
import { dataPaths } from "../../config.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { runSandbox, SandboxError } from "../../sandbox/client.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer):
|
||||
// 解析(编码转 UTF-8、语言、季集)→ 配对到作品的视频 → 写到视频同目录(主干名 + 语言后缀)→ 登记字幕资产 + 历史,可撤销。
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_ENTRIES = 300;
|
||||
|
||||
const b64 = (bytes: Uint8Array) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(s);
|
||||
};
|
||||
const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
const fileName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p;
|
||||
|
||||
@@ -133,60 +123,25 @@ export async function parseSubtitles(
|
||||
episode: t.episode,
|
||||
}))
|
||||
: [];
|
||||
const items: SubtitleParsedItem[] = [];
|
||||
const skipped: SubtitleParseResponse["skipped"] = [];
|
||||
const one = (name: string, bytes: Uint8Array, archive: string | null) => {
|
||||
const ext = subtitleExtOf(name);
|
||||
if (!ext) return skipped.push({ name, reason: "不是字幕文件" });
|
||||
if (bytes.length > MAX_FILE_BYTES) return skipped.push({ name, reason: "文件超过 10 MB" });
|
||||
const { text, encoding } = decodeSubtitle(bytes);
|
||||
const ep = subtitleEpisode(name);
|
||||
items.push({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
archive,
|
||||
ext,
|
||||
language: detectSubtitleLanguage(name, text) as SubtitleLanguage,
|
||||
season: ep.season,
|
||||
seasonInferred: ep.seasonInferred,
|
||||
episode: ep.episode,
|
||||
encoding,
|
||||
sizeBytes: bytes.length,
|
||||
content: b64(new TextEncoder().encode(text)),
|
||||
targetLocationId: targets.length ? pairSubtitle(ep, targets) : null,
|
||||
});
|
||||
};
|
||||
for (const f of files) {
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = unb64(f.base64);
|
||||
} catch {
|
||||
skipped.push({ name: f.name, reason: "内容不是有效的 base64" });
|
||||
continue;
|
||||
}
|
||||
if (/\.zip$/i.test(f.name)) {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
let n = 0;
|
||||
entries = unzipSync(bytes, {
|
||||
filter: (e) =>
|
||||
!e.name.endsWith("/") &&
|
||||
!!subtitleExtOf(e.name) &&
|
||||
e.originalSize <= MAX_FILE_BYTES &&
|
||||
++n <= MAX_ARCHIVE_ENTRIES,
|
||||
});
|
||||
} catch {
|
||||
skipped.push({ name: f.name, reason: "压缩包损坏或加密" });
|
||||
continue;
|
||||
}
|
||||
const names = Object.keys(entries);
|
||||
if (!names.length) skipped.push({ name: f.name, reason: "压缩包里没有字幕文件" });
|
||||
for (const n of names) one(n, entries[n]!, f.name);
|
||||
} else if (/\.(rar|7z)$/i.test(f.name)) {
|
||||
skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" });
|
||||
} else one(f.name, bytes, null);
|
||||
// 上传 / 下载的字幕与压缩包不可信:解压、解码、识别都在沙箱子进程里做(docs/adr/ADR-005-sandbox.md)
|
||||
let parsed: Awaited<ReturnType<typeof runSandbox<"subtitle.parse">>>;
|
||||
try {
|
||||
parsed = await runSandbox("subtitle.parse", { files }, { timeoutMs: 30_000 });
|
||||
} catch (e) {
|
||||
if (e instanceof SandboxError) throw new ApiException("bad_request", `字幕解析失败:${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
return { items, skipped };
|
||||
const items: SubtitleParsedItem[] = parsed.items.map((it) => {
|
||||
const ep = { season: it.season, seasonInferred: it.seasonInferred, episode: it.episode };
|
||||
return {
|
||||
...it,
|
||||
id: crypto.randomUUID(),
|
||||
ext: it.ext as SubtitleExt,
|
||||
language: it.language as SubtitleLanguage,
|
||||
targetLocationId: targets.length ? pairSubtitle(ep, targets) : null,
|
||||
};
|
||||
});
|
||||
return { items, skipped: parsed.skipped };
|
||||
}
|
||||
|
||||
// ── 写入 ──
|
||||
|
||||
Reference in new issue
Block a user