fix(mteam): follow subtitle download redirects without forwarding credentials

This commit is contained in:
root committed 2026-11-06 00:11:08 +08:00
1 parent 173f2e24c9
commit 6dab134617
2 files changed
+125 -7

No files matched your search

@@ -7,6 +7,7 @@ import { staticSecretResolver } from "../types.ts";
import { createMteamAdapter } from "./adapter.ts";
import { mteamApiBase, mteamDefinition } from "./definition.ts";
import { mteamSearchParams } from "./spider.ts";
import { parseMteamSubtitles } from "./subtitles.ts";
function adapter(fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
return createMteamAdapter(
@@ -20,6 +21,87 @@ function adapter(fetch: (input: RequestInfo | URL, init?: RequestInit) => Promis
}
describe("M-Team v3.2.4 API", () => {
it("下载次数仅返回非负整数,秒与毫秒时间戳解析到同一UTC时刻", () => {
const rows = parseMteamSubtitles(
[
{ id: "1", hits: "12", createdDate: 1_700_000_000 },
{ id: "2", hits: 1.25, createdDate: "1700000000000" },
{ id: "3", hits: -1, createdDate: 1_700_000_000.9 },
{ id: "4", hits: "invalid", createdDate: "99999999999999999" },
],
mteamDefinition.domain,
"+08:00",
);
expect(rows.map((row) => row.downloads)).toEqual([12, null, null, null]);
expect(rows.map((row) => row.uploadedAt)).toEqual([
"2023-11-14T22:13:20.000Z",
"2023-11-14T22:13:20.000Z",
"2023-11-14T22:13:20.000Z",
null,
]);
});
it("字幕302可先相对跳转再到签名CDN,各跳均不转发API Key或Cookie", async () => {
const mock = createMockFetch({
"/api/subtitle/genlink": { body: { code: 0, data: "initial-credential" } },
"/api/subtitle/dlV2": {
status: 302,
headers: { location: "/signed/step?opaque=first-signature", "set-cookie": "session=private" },
},
"/signed/step": { status: 302, headers: { location: "https://cdn.example/subtitle?opaque=cdn-signature" } },
"https://cdn.example/subtitle": {
body: "subtitle data",
headers: { "content-type": "application/octet-stream", "content-disposition": "attachment; filename=demo.srt" },
},
});
const inits: RequestInit[] = [];
const client = adapter(async (input, init) => {
inits.push(init ?? {});
return mock.fetch(input, init);
});
const result = await client.downloadSubtitle!("sub-200");
expect(new TextDecoder().decode(result.bytes)).toBe("subtitle data");
expect(result.fileName).toBe("demo.srt");
expect(mock.requests.map((request) => request.url.hostname)).toEqual([
"subtitles.example",
"subtitles.example",
"subtitles.example",
"cdn.example",
]);
for (const request of mock.requests.slice(1)) {
expect(request.headers["x-api-key"]).toBeUndefined();
expect(request.headers.cookie).toBeUndefined();
expect(request.headers.authorization).toBeUndefined();
expect(request.headers.referer).toBeUndefined();
}
for (const init of inits.slice(1))
expect(init).toMatchObject({
method: "GET",
redirect: "manual",
credentials: "omit",
referrerPolicy: "no-referrer",
});
expect(JSON.stringify({ ...result, bytes: undefined })).not.toContain("signature");
});
it("跳转循环与CDN网络错误不暴露任意名称的签名参数", async () => {
const loop = createMockFetch({
"/api/subtitle/genlink": { body: { code: 0, data: "temporary-secret" } },
"/api/subtitle/dlV2": { status: 302, headers: { location: "?opaque=loop-secret" } },
});
await expect(adapter(loop.fetch).downloadSubtitle!("sub-200")).rejects.toThrow("M-Team 字幕下载跳转无效或次数过多");
const failed = createMockFetch({
"/api/subtitle/genlink": { body: { code: 0, data: "temporary-secret" } },
"/api/subtitle/dlV2": { status: 302, headers: { location: "https://cdn.example/subtitle?opaque=leak-secret" } },
"https://cdn.example/subtitle": () => {
throw new Error("request https://cdn.example/subtitle?opaque=leak-secret failed");
},
});
await expect(adapter(failed.fetch).downloadSubtitle!("sub-200")).rejects.toMatchObject({
kind: "network",
message: "M-Team 字幕下载请求失败",
});
});
it("专用分派同样透传分类和分页元数据,按原始页数判断尾页", async () => {
const mock = createMockFetch({
"/api/torrent/search": {
+43 -7
View File
@@ -29,8 +29,10 @@ export function parseMteamSubtitles(data: unknown, webUrl: string, timezone: str
const torrentId = string(isPlainObject(row.torrent) ? row.torrent.id : row.torrent);
const created = row.createdDate ?? row.lastModifiedDate;
let uploadedAt: string | null = null;
if (created != null && /^\d+(?:\.\d+)?$/.test(String(created))) {
const date = new Date(Number(created) * 1000);
if (created != null && /^-?\d+(?:\.\d+)?$/.test(String(created))) {
const timestamp = Math.trunc(Number(created));
// 上游 format_timestamp 以秒为单位;另兼容站点返回的常见13位毫秒值。
const date = new Date(Math.abs(timestamp) >= 1_000_000_000_000 ? timestamp : timestamp * 1000);
if (Number.isFinite(date.getTime())) uploadedAt = date.toISOString();
} else if (typeof created === "string") {
uploadedAt = isoWithZoneToUtc(created) ?? parseSiteTime(created, timezone);
@@ -44,7 +46,7 @@ export function parseMteamSubtitles(data: unknown, webUrl: string, timezone: str
language: string(row.lang ?? row.language),
sizeBytes: row.size == null ? null : parseSizeMp(String(row.size)),
uploadedAt,
downloads: Number.isFinite(downloads) ? downloads : null,
downloads: Number.isSafeInteger(downloads) && downloads >= 0 ? downloads : null,
uploader: string(row.author),
torrentId,
detailUrl: torrentId
@@ -68,6 +70,43 @@ function fileName(headers: Headers): string | null {
return /filename="([^"]+)"/i.exec(header)?.[1] ?? /filename=([^;\s]+)/i.exec(header)?.[1] ?? null;
}
/** 文件下载逐跳取值;每跳重新构造无凭据GET,CDN签名地址始终只在内存中使用。 */
async function fetchSubtitleFile(http: SiteHttpContext, initial: URL, signal?: AbortSignal): Promise<Response> {
let url = initial;
for (let redirects = 0; ; redirects++) {
let response: Response;
try {
response = await siteFetch(http, url.href, {
method: "GET",
redirect: "manual",
credentials: "omit",
referrerPolicy: "no-referrer",
headers: { accept: "application/octet-stream" },
...(signal ? { signal } : {}),
});
} catch (error) {
if (signal?.aborted) throw error;
// 签名参数未必叫credential/token;不把传输层可能包含完整CDN地址的消息带回调用方。
throw new SiteError(http.siteId, error instanceof SiteError ? error.kind : "network", "M-Team 字幕下载请求失败", {
...(error instanceof SiteError && error.httpStatus !== undefined ? { httpStatus: error.httpStatus } : {}),
...(error instanceof SiteError && error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
});
}
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
await response.body?.cancel().catch(() => {});
const location = response.headers.get("location");
if (!location || redirects >= 5) throw new SiteError(http.siteId, "network", "M-Team 字幕下载跳转无效或次数过多");
try {
const next = new URL(location, url);
if (!["http:", "https:"].includes(next.protocol) || next.username || next.password)
throw new Error("invalid redirect");
url = next;
} catch {
throw new SiteError(http.siteId, "network", "M-Team 字幕下载跳转地址无效");
}
}
}
export function createMteamSubtitleClient(options: {
http: SiteHttpContext;
apiBase: string;
@@ -129,10 +168,7 @@ export function createMteamSubtitleClient(options: {
throw new SiteError(http.siteId, "parse_error", "M-Team 字幕接口未返回下载凭据");
const url = new URL(`${apiBase.replace(/\/+$/, "")}/api/subtitle/dlV2`);
url.searchParams.set("credential", credential);
const response = await siteFetch(http, url.href, {
redirect: "manual",
...(opts.signal ? { signal: opts.signal } : {}),
});
const response = await fetchSubtitleFile(http, url, opts.signal);
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new SiteError(