fix(mteam): follow subtitle download redirects without forwarding credentials
This commit is contained in:
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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user