[Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 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>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user