- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
9.4 KiB
TypeScript
276 lines
9.4 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import {
|
|
imageCDNBases,
|
|
imageSourceURLs,
|
|
validProfileImage,
|
|
} from "../src/lib/media/image-source";
|
|
import {
|
|
decodeImageBytes,
|
|
decryptImageBytes,
|
|
imageMime,
|
|
resolveImage,
|
|
} from "../src/lib/media/images";
|
|
|
|
const primary = "https://primary.images.test/assets/";
|
|
const backup = "https://backup.images.test/media/";
|
|
const signedPath = "v3/image/cover.jpg?ticket=a%2Bb%3D&expires=99&path=%2Ffoo";
|
|
const bytesOf = (path: string): Uint8Array =>
|
|
new Uint8Array(readFileSync(new URL(path, import.meta.url)));
|
|
const png = bytesOf("../public/static/favicon.png");
|
|
const fixtures = [
|
|
["PNG", "image/png", png],
|
|
[
|
|
"JPEG",
|
|
"image/jpeg",
|
|
bytesOf("../public/static/images/profile/water-margin.jpg"),
|
|
],
|
|
[
|
|
"WebP",
|
|
"image/webp",
|
|
new Uint8Array(
|
|
Buffer.from("UklGRhwAAABXRUJQVlA4TBAAAAAvAAAAAAcQ/S9g/wMR0f8A", "base64"),
|
|
),
|
|
],
|
|
[
|
|
"GIF",
|
|
"image/gif",
|
|
new Uint8Array(
|
|
Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"),
|
|
),
|
|
],
|
|
] as const;
|
|
const bufferOf = (bytes: Uint8Array): ArrayBuffer =>
|
|
Uint8Array.from(bytes).buffer;
|
|
|
|
test("CDN bases retain deployment paths, deduplicate, and reject unsafe configuration", () => {
|
|
assert.deepEqual(
|
|
imageCDNBases(
|
|
" https://primary.images.test/assets ",
|
|
primary,
|
|
"http://backup.images.test/media/",
|
|
"https://user:secret@private.images.test/",
|
|
"https://query.images.test/?token=secret",
|
|
"https://hash.images.test/#fragment",
|
|
"javascript:alert(1)",
|
|
"data:image/png;base64,abc",
|
|
"//relative.images.test/",
|
|
"https://",
|
|
"",
|
|
null,
|
|
42,
|
|
{},
|
|
),
|
|
[primary, "http://backup.images.test/media/"],
|
|
);
|
|
});
|
|
|
|
test("relative API images use the CDN base or the site root on every page", () => {
|
|
for (const source of [signedPath, "/" + signedPath]) {
|
|
assert.deepEqual(imageSourceURLs(source), ["/" + signedPath]);
|
|
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [
|
|
primary + signedPath,
|
|
backup + signedPath,
|
|
]);
|
|
}
|
|
});
|
|
|
|
test("absolute signed image URLs remain byte-for-byte intact and only configured CDN paths gain fallbacks", () => {
|
|
const source = primary + signedPath;
|
|
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [
|
|
source,
|
|
backup + signedPath,
|
|
]);
|
|
assert.deepEqual(imageSourceURLs(backup + signedPath, [primary, backup]), [
|
|
backup + signedPath,
|
|
source,
|
|
]);
|
|
for (const outside of [
|
|
"https://external.images.test/" + signedPath,
|
|
"https://primary.images.test/assets-other/" + signedPath,
|
|
])
|
|
assert.deepEqual(imageSourceURLs(outside, [primary, backup]), [outside]);
|
|
});
|
|
|
|
test("static, inline and blob sources bypass rewriting while executable sources are rejected", () => {
|
|
for (const source of [
|
|
"/static/brand/placeholder.svg?v=1",
|
|
"data:image/png;base64,iVBORw0KGgo=",
|
|
"blob:https://site.test/image-id",
|
|
])
|
|
assert.deepEqual(imageSourceURLs(source, [primary, backup]), [source]);
|
|
for (const source of [
|
|
"javascript:alert(1)",
|
|
"data:text/html,<script>alert(1)</script>",
|
|
"file:///private/image.png",
|
|
"vbscript:msgbox(1)",
|
|
"https://",
|
|
"",
|
|
])
|
|
assert.deepEqual(imageSourceURLs(source, [primary, backup]), []);
|
|
});
|
|
|
|
test("profile avatars accept real API image paths without accepting executable or malformed HTTP URLs", () => {
|
|
for (const source of [
|
|
"https://avatars.images.test/face.jpg?signature=a%2Bb%3D",
|
|
"http://avatars.images.test/face.png",
|
|
"/static/images/profile/avatar.png?v=1",
|
|
signedPath,
|
|
"/" + signedPath,
|
|
])
|
|
assert.equal(validProfileImage(source), source);
|
|
for (const source of [
|
|
null,
|
|
42,
|
|
{},
|
|
"",
|
|
"javascript:alert(1)",
|
|
"data:text/html,unsafe",
|
|
"https://",
|
|
"http://[malformed",
|
|
"https://bad host/image.png",
|
|
"ordinary-relative-avatar.png",
|
|
])
|
|
assert.equal(validProfileImage(source), "");
|
|
});
|
|
|
|
test("image decoding accepts complete raw and legacy XOR images without mutating their input", () => {
|
|
for (const [name, mime, original] of fixtures) {
|
|
const plainBuffer = bufferOf(original);
|
|
assert.deepEqual(decodeImageBytes(plainBuffer), original, name + " raw");
|
|
assert.equal(imageMime(decodeImageBytes(plainBuffer)), mime, name + " type");
|
|
assert.deepEqual(new Uint8Array(plainBuffer), original, name + " raw input");
|
|
const encrypted = decryptImageBytes(bufferOf(original));
|
|
const encryptedBuffer = bufferOf(encrypted);
|
|
assert.deepEqual(
|
|
decodeImageBytes(encryptedBuffer),
|
|
original,
|
|
name + " encrypted",
|
|
);
|
|
assert.deepEqual(
|
|
new Uint8Array(encryptedBuffer),
|
|
encrypted,
|
|
name + " encrypted input",
|
|
);
|
|
}
|
|
});
|
|
|
|
test("image decoding rejects empty bodies and non-image API error responses", () => {
|
|
for (const value of [
|
|
"",
|
|
'{"code":404,"data":null,"message":"image missing"}',
|
|
"<!doctype html><html><body>CDN unavailable</body></html>",
|
|
"upstream unavailable",
|
|
]) {
|
|
const bytes = new TextEncoder().encode(value);
|
|
assert.throws(() => decodeImageBytes(bufferOf(bytes)), value || "empty");
|
|
if (bytes.length)
|
|
assert.throws(() =>
|
|
decodeImageBytes(bufferOf(decryptImageBytes(bufferOf(bytes)))),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("image fetching validates bytes, retries alternate CDNs, and only caches successful images", async (t) => {
|
|
const originalFetch = globalThis.fetch;
|
|
const created: string[] = [];
|
|
const responseFor = (bytes: Uint8Array, contentType = "image/png") =>
|
|
new Response(bufferOf(bytes), { headers: { "Content-Type": contentType } });
|
|
const captureBlob = async (url: string, expected: Uint8Array, mime: string) => {
|
|
assert.match(url, /^blob:/);
|
|
created.push(url);
|
|
const response = await originalFetch(url);
|
|
assert.equal(response.headers.get("content-type"), mime);
|
|
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), expected);
|
|
};
|
|
try {
|
|
await t.test("HTTP failure uses the next CDN and retains its successful cache", async () => {
|
|
const path = "v3/image/http-fallback-20261012.png?signature=x%2By%3D";
|
|
const calls: string[] = [];
|
|
globalThis.fetch = async (input) => {
|
|
const url = String(input);
|
|
calls.push(url);
|
|
return url === primary + path
|
|
? new Response("CDN unavailable", { status: 503 })
|
|
: responseFor(decryptImageBytes(bufferOf(png)));
|
|
};
|
|
const first = await resolveImage(path, true, [primary, backup]);
|
|
await captureBlob(first, png, "image/png");
|
|
assert.deepEqual(calls, [primary + path, backup + path]);
|
|
assert.equal(await resolveImage(path, false, [primary, backup]), first);
|
|
assert.equal(calls.length, 2);
|
|
});
|
|
|
|
await t.test("a JSON 200 response is rejected before trying the alternate CDN", async () => {
|
|
const path = "v3/image/json-fallback-20261012.png";
|
|
const calls: string[] = [];
|
|
globalThis.fetch = async (input) => {
|
|
const url = String(input);
|
|
calls.push(url);
|
|
return url === primary + path
|
|
? new Response('{"code":404,"data":null}', {
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
: responseFor(png);
|
|
};
|
|
await captureBlob(
|
|
await resolveImage(primary + path, false, [primary, backup]),
|
|
png,
|
|
"image/png",
|
|
);
|
|
assert.deepEqual(calls, [primary + path, backup + path]);
|
|
});
|
|
|
|
await t.test("a missing CDN roots the API path before requesting valid bytes", async () => {
|
|
const path = "v3/image/no-cdn-20261012.png?signature=a%2Bb%3D";
|
|
const calls: string[] = [];
|
|
globalThis.fetch = async (input) => {
|
|
calls.push(String(input));
|
|
return responseFor(png);
|
|
};
|
|
await captureBlob(await resolveImage(path), png, "image/png");
|
|
assert.deepEqual(calls, ["/" + path]);
|
|
});
|
|
|
|
await t.test("invalid image responses are not cached and the same URL can recover", async () => {
|
|
const source = "https://unconfigured.images.test/no-cache-20261012.png";
|
|
let count = 0;
|
|
let recovered = false;
|
|
globalThis.fetch = async () => {
|
|
count++;
|
|
return recovered
|
|
? responseFor(png)
|
|
: new Response('{"code":500,"tip":"failed"}', {
|
|
headers: { "Content-Type": "image/png" },
|
|
});
|
|
};
|
|
await assert.rejects(resolveImage(source));
|
|
await assert.rejects(resolveImage(source));
|
|
assert.equal(count, 2, "invalid responses must trigger a fresh request");
|
|
recovered = true;
|
|
const resolved = await resolveImage(source);
|
|
await captureBlob(resolved, png, "image/png");
|
|
assert.equal(count, 3);
|
|
assert.equal(await resolveImage(source), resolved);
|
|
assert.equal(count, 3, "successful bytes may be cached");
|
|
});
|
|
|
|
await t.test("non-image sources and illegal protocols never enter the image fetcher", async () => {
|
|
let count = 0;
|
|
globalThis.fetch = async () => {
|
|
count++;
|
|
throw new Error("Unexpected image request");
|
|
};
|
|
const source = "/static/brand/placeholder.svg?v=image-test";
|
|
assert.equal(await resolveImage(source), source);
|
|
assert.equal(count, 0);
|
|
await assert.rejects(resolveImage("javascript:alert(1)"));
|
|
assert.equal(count, 0);
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
for (const url of created) URL.revokeObjectURL(url);
|
|
}
|
|
});
|