Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-27 16:23:48 +07:00
co-authored by Claude Opus 5.5
commit 8cdbfee08f
345 changed files with 20773 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
const [origin,environment]=process.argv.slice(2);
if(!origin||!['test','production'].includes(environment))throw new Error('Usage: node scripts/check-health.mjs <origin> test|production');
async function read(path) {
const response=await fetch(new URL(path,origin),{signal:AbortSignal.timeout(5000),redirect:'error',cache:'no-store'});
if(response.status!==200)throw new Error(`${path} HTTP ${response.status}`);
let text='',size=0;
const decoder=new TextDecoder();
for await(const part of response.body) {
size+=part.length;if(size>1048576)throw new Error(`${path} 响应超过检查上限`);
text+=decoder.decode(part,{stream:true});
if(path==='/'&&text.includes('/_next/'))break;
}
return {text,type:response.headers.get('content-type')||''};
}
let healthy=false;
for(let attempt=1;attempt<=5;attempt++) {
try {
const health=await read('/healthz'),status=JSON.parse(health.text);
if(status.status!=='ok'||status.environment!==environment||status.application!=='91-drama-next')throw new Error('/healthz 未命中本站对应环境');
const home=await read('/');
if(!/^text\/html(?:;|$)/i.test(home.type)||!home.text.includes('/_next/'))throw new Error('首页不是 Next HTML');
healthy=true;console.log('/healthz 与 Next 首页检查通过');break;
}catch(error) {
console.error(`健康检查 ${attempt}/5 失败:${error.message}`);
if(attempt<5)await new Promise(resolve=>setTimeout(resolve,1000));
}
}
process.exitCode=healthy?0:1;
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Shared by deploy_test.sh / deploy_prod.sh; compatible with macOS Bash 3.2.
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; }
need() { command -v "$1" >/dev/null || fail "缺少命令 $1"; }
valid_host() { [[ "$1" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail "$2 格式无效或未配置"; }
valid_path() { [[ "$1" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*/?$ ]] && [[ "$1/" != *'/../'* ]] && [[ "$1/" != *'/./'* ]] || fail "$2 必须是普通绝对路径"; }
valid_port() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ] || fail "$2 必须在 1–65535 内"; }
check_local() {
need node; need npm
node -e 'const [a,b]=process.versions.node.split(".").map(Number);if(a<20||a===20&&b<9)process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22.17.1'
[ -d node_modules/next ] && [ -d node_modules/dotenv ] || fail '请先执行 npm ci 安装本地依赖'
[ -s "config/$1.env" ] && [ -s package-lock.json ] || fail "缺少 config/$1.env 或 package-lock.json"
}
public_profile() {
node --input-type=module -e 'import{readFileSync}from"node:fs";import{parse}from"dotenv";const c=parse(readFileSync(`config/${process.argv[1]}.env`));console.log(` API=${c.UPSTREAM_API_URL||c.API_BASE_URL} PORT=${c.PORT}`)' "$1"
}
lock_build() {
[ ! -L dist ] || fail 'dist 不允许为符号链接'
mkdir -p dist
DEPLOY_LOCK="$SCRIPT_DIR/dist/.deploy.lock"
mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有构建/发布进程或残留锁:$DEPLOY_LOCK"
trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
}
+68
View File
@@ -0,0 +1,68 @@
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {spawn} from 'node:child_process';
import {parse} from 'dotenv';
const projectRoot=fileURLToPath(new URL('../',import.meta.url));
const directories={development:'.next-dev',test:'.next-test',production:'.next'};
const commands=new Set(['dev','build','start']);
const readConfig=file=>existsSync(file)?parse(readFileSync(file)):{};
// Normalize each layer before merging so a legacy process override still wins over a profile.
const apiConfig=values=>({...values,...(!Object.hasOwn(values,'UPSTREAM_API_URL')&&Object.hasOwn(values,'API_BASE_URL')?{UPSTREAM_API_URL:values.API_BASE_URL}:{})});
/** APP_ENV selects the deployment; NODE_ENV keeps Next's development/production semantics. */
export function nextCommand(environment='development',command='dev',args=[],{root=projectRoot,variables=process.env}={}) {
if(!Object.hasOwn(directories,environment)||!commands.has(command))throw new Error('Usage: node scripts/next-env.mjs development|test|production dev|build|start');
const base=resolve(root,'config',environment+'.env');
if(!existsSync(base))throw new Error(`缺少环境配置:config/${environment}.env`);
const env={
...apiConfig(readConfig(base)),
// Preserve the old local-development setup without leaking it into test/production.
...(environment==='development'?apiConfig(readConfig(resolve(root,'.env.local'))):{}),
...apiConfig(readConfig(resolve(root,'config',environment+'.local.env'))),
...apiConfig(Object.fromEntries(Object.entries(variables).filter(([,value])=>value!==undefined))),
APP_ENV:environment,
APP_DIST_DIR:directories[environment],
NODE_ENV:command==='dev'?'development':'production',
};
try {
const value=(env.UPSTREAM_API_URL||'').trim();
if(value==='current')env.UPSTREAM_API_URL=value;
else{
const api=new URL(value);
if(!['http:','https:'].includes(api.protocol)||api.username||api.password||api.search||api.hash)throw new Error();
env.UPSTREAM_API_URL=api.href.replace(/\/+$/,'');
}
env.API_BASE_URL=env.UPSTREAM_API_URL;
}catch{throw new Error(`config/${environment}.env 的 UPSTREAM_API_URL(兼容 API_BASE_URL)必须为 current 或有效的 HTTP(S) 接口地址`);}
const port=String(env.PORT||'21021');
if(!/^\d+$/.test(port)||Number(port)<1||Number(port)>65535)throw new Error('PORT 必须在 1–65535 之间');
env.PORT=port;
const hasOption=names=>args.some(arg=>names.some(name=>arg===name||arg.startsWith(name+'=')));
const options=command==='build'?[]:[
...(hasOption(['--hostname','-H'])?[]:['--hostname','0.0.0.0']),
...(hasOption(['--port','-p'])?[]:['--port',port]),
];
return {executable:process.execPath,args:[resolve(root,'node_modules/next/dist/bin/next'),command,...options,...args],cwd:root,env};
}
export async function runNext(environment,command,args,options) {
const spec=nextCommand(environment,command,args,options);
const child=spawn(spec.executable,spec.args,{cwd:spec.cwd,env:spec.env,stdio:'inherit'});
const interrupt=()=>child.kill('SIGINT'),terminate=()=>child.kill('SIGTERM');
process.on('SIGINT',interrupt);process.on('SIGTERM',terminate);
try {
return await new Promise((resolve,reject)=>{
child.once('error',reject);
child.once('exit',(code,signal)=>resolve(code??(signal==='SIGINT'?130:143)));
});
}finally{process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);}
}
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
try {
const [environment='development',command='dev',...args]=process.argv.slice(2);
process.exitCode=await runNext(environment,command,args);
}catch(error){console.error(error instanceof Error?error.message:'启动 Next.js 失败');process.exitCode=1;}
}
+61
View File
@@ -0,0 +1,61 @@
import {cp, lstat, mkdir, readFile, realpath, rm, writeFile} from 'node:fs/promises';
import {basename, join, relative, resolve, sep} from 'node:path';
import {fileURLToPath} from 'node:url';
const projectRoot=fileURLToPath(new URL('../',import.meta.url));
/** Assemble a relocatable release; only the selected public environment profile is shipped. */
export async function packageStandalone(environment,{root=projectRoot}={}) {
if(!['test','production'].includes(environment))throw new Error('Usage: node scripts/package-standalone.mjs test|production');
root=await realpath(root);
const build=environment==='test'?'.next-test':'.next';
const dist=join(root,'dist'),destination=join(dist,environment),source=join(root,build,'standalone');
async function directory(path,optional=false) {
const info=await lstat(path).catch(error=>{if(optional&&error.code==='ENOENT')return null;throw error;});
if(info&&(!info.isDirectory()||info.isSymbolicLink()||await realpath(path)!==path))throw new Error(`Unsafe release directory: ${path}`);
}
for(const path of [join(root,build),source])await directory(path);
for(const file of [join(source,'server.js'),join(source,build,'BUILD_ID'),join(root,'config',environment+'.env')]) {
if(!(await lstat(file)).isFile()||!(await readFile(file)).length)throw new Error(`Missing release file: ${file}`);
}
await directory(dist,true);await mkdir(dist,{recursive:true});await directory(destination,true);
// A fixed dist/<environment> is the only location removed by this command.
await rm(destination,{recursive:true,force:true});
const filter=async path=>{
const name=basename(path);
if(name.startsWith('.env')||name.endsWith('.local.env')||name.startsWith('._')||name==='.DS_Store'||name.endsWith('.log'))return false;
// Dynamic filesystem reads can make Next trace sibling build/cache/test directories.
// Keep this project's runtime roots, never recursively include earlier dist releases.
if(path.startsWith(source+sep)&&!['server.js','package.json','package-lock.json',build,'node_modules','src'].includes(relative(source,path).split(sep)[0]))return false;
if(path===join(source,build,'cache'))return false;
if((await lstat(path)).isSymbolicLink())throw new Error(`Release source contains a symbolic link: ${path}`);
return true;
};
await cp(source,destination,{recursive:true,filter});
for(const [from,to] of [[join(root,'public'),'public'],[join(root,build,'static'),build+'/static'],
[join(root,'node_modules/dotenv'),'node_modules/dotenv']]) {
await cp(from,join(destination,to),{recursive:true,filter});
}
await mkdir(join(destination,'config'),{recursive:true});await mkdir(join(destination,'scripts'),{recursive:true});
for(const file of ['package.json','package-lock.json','scripts/next-env.mjs','scripts/check-health.mjs','config/'+environment+'.env']) {
await cp(join(root,file),join(destination,file));
}
await writeFile(join(destination,'start.mjs'),`import {fileURLToPath} from 'node:url';
import {nextCommand} from './scripts/next-env.mjs';
const root=fileURLToPath(new URL('./',import.meta.url));
process.chdir(root);
const {env}=nextCommand(${JSON.stringify(environment)},'start',[],{root});
Object.assign(process.env,env);
process.env.HOSTNAME=process.env.HOSTNAME||'0.0.0.0';
await import('./server.js');
`);
console.log(`Standalone release ready: ${destination}`);
return destination;
}
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
try {
if(process.argv.length!==3)throw new Error('Usage: node scripts/package-standalone.mjs test|production');
await packageStandalone(process.argv[2]);
}catch(error){console.error(error.message);process.exitCode=1;}
}
+163
View File
@@ -0,0 +1,163 @@
import { cp, lstat, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
const [arch = 'x64', libc = 'glibc', ...extra] = process.argv.slice(2);
if (!['x64', 'arm64'].includes(arch) || !['glibc', 'musl'].includes(libc) || extra.length) {
throw new Error('Usage: node scripts/prepare-production-runtime.mjs [x64|arm64] [glibc|musl]');
}
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
const dist = join(root, 'dist');
const source = join(dist, 'production');
const target = join(dist, `production-linux-${arch}-${libc}`);
if (dirname(target) !== dist || !/^production-linux-(x64|arm64)-(glibc|musl)$/.test(basename(target))) {
throw new Error('Runtime destination must be a fixed repository dist directory');
}
for (const directory of [dist, source]) {
const info = await lstat(directory);
if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory) {
throw new Error(`Unsafe build directory: ${directory}`);
}
}
const oldTarget = await lstat(target).catch((error) => {
if (error.code !== 'ENOENT')
throw error;
return null;
});
if (oldTarget?.isSymbolicLink())
throw new Error('Runtime destination cannot be a symbolic link');
async function requireFile(base, name) {
const path = join(base, name);
const info = await lstat(path);
if (!info.isFile() || info.size === 0 || !(await realpath(path)).startsWith(base + '/')) {
throw new Error(`Missing, empty or unsafe runtime file: ${name}`);
}
}
const required = ['start.mjs', 'server.js', '.next/BUILD_ID', 'config/production.env'];
for (const file of required)
await requireFile(source, file);
for (const directory of ['.next/static', 'public']) {
if (!(await lstat(join(source, directory))).isDirectory())
throw new Error(`Missing ${directory}`);
}
for (const file of ['package.json', 'package-lock.json'])
await requireFile(root, file);
const originalLock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8'));
for (const file of await matchingFiles(join(source, 'node_modules'), /^package\.json$/)) {
const locked = originalLock.packages[relative(source, dirname(file))];
if (locked && JSON.parse(await readFile(file, 'utf8')).version !== locked.version) {
throw new Error(`Traced dependency does not match the root lockfile: ${file}`);
}
}
const npmVersion = spawnSync('npm', ['--version'], { cwd: root, encoding: 'utf8' });
if (npmVersion.error || npmVersion.status !== 0)
throw new Error('Cannot read the current npm version');
const version = npmVersion.stdout.trim();
const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/.exec(version);
const [major = 0, minor = 0, patch = 0] = match ? match.slice(1).map(Number) : [];
if (major < 10 || major === 10 && (minor < 9 || minor === 9 && patch < 2)) {
throw new Error(`npm >=10.9.2 is required for cross-platform installation; found ${version}`);
}
// Only this validated, fixed dist destination can be removed; source and host dependencies stay intact.
const sharpPlatform = `${libc === 'musl' ? 'linuxmusl' : 'linux'}-${arch}`;
const packages = [
[`@img/sharp-${sharpPlatform}`, /\.node$/],
[`@img/sharp-libvips-${sharpPlatform}`, /libvips.*\.so(?:\.\d+)*$/],
];
await rm(target, { recursive: true, force: true });
await cp(source, target, {
recursive: true,
filter: async (path) => {
const name = basename(path);
if (name.startsWith('.env') || name.endsWith('.local.env') ||
name.startsWith('._') || name === '.DS_Store' || name.endsWith('.log'))
return false;
const scope = basename(dirname(path));
if (scope === '@next' && name.startsWith('swc-'))
return false;
if (scope === '@img' && name.startsWith('sharp-') && name !== 'sharp-wasm32' &&
!packages.some(([packageName]) => packageName === `@img/${name}`))
return false;
if ((await lstat(path)).isSymbolicLink())
throw new Error(`Runtime source contains a symbolic link: ${path}`);
return true;
},
});
for (const file of ['package.json', 'package-lock.json'])
await cp(join(root, file), join(target, file));
// Preserve Next's traced JS tree. Install only the two platform packages, never the full app dependency graph.
const nativeDir = await mkdtemp(join(dist, '.production-native-'));
try {
const optionalDependencies = {}, records = {};
for (const [name] of packages) {
const entry = originalLock.packages[`node_modules/${name}`];
if (!entry?.version || !entry.resolved || !entry.integrity)
throw new Error(`Missing locked native package: ${name}`);
optionalDependencies[name] = entry.version;
const record = { ...entry, optional: true };
delete record.dev;
delete record.devOptional;
records[`node_modules/${name}`] = record;
}
const manifest = { name: '91dj-native-runtime', version: '1.0.0', private: true, optionalDependencies };
await writeFile(join(nativeDir, 'package.json'), JSON.stringify(manifest));
await writeFile(join(nativeDir, 'package-lock.json'), JSON.stringify({ name: manifest.name,
version: manifest.version, lockfileVersion: 3, requires: true, packages: { '': manifest, ...records } }));
console.log(`Installing two locked Linux ${arch}/${libc} native packages with npm ${version}`);
const install = spawnSync('npm', ['ci', '--omit=dev', '--include=optional', '--ignore-scripts',
'--no-audit', '--no-fund', '--os=linux', `--cpu=${arch}`, `--libc=${libc}`], { cwd: nativeDir, stdio: 'inherit' });
if (install.error || install.status !== 0)
throw new Error(`Native npm ci failed (${install.status ?? install.error?.message})`);
for (const [name, pattern] of packages) {
const directory = join(nativeDir, 'node_modules', name);
const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'));
if (metadata.name !== name || metadata.version !== optionalDependencies[name] ||
!metadata.os?.includes('linux') || !metadata.cpu?.includes(arch))
throw new Error(`Incorrect native package: ${name}`);
const binaries = await matchingFiles(directory, pattern);
if (!binaries.length)
throw new Error(`Native runtime binary is missing: ${name}`);
for (const binary of binaries)
await verifyELF(binary);
await cp(directory, join(target, 'node_modules', name), { recursive: true });
}
}
finally {
await rm(nativeDir, { recursive: true, force: true });
}
for (const file of [...required, 'node_modules/dotenv/package.json', 'node_modules/sharp/package.json',
'node_modules/detect-libc/package.json', 'node_modules/semver/package.json', 'node_modules/@img/colour/package.json']) {
await requireFile(target, file);
}
const runtimeRequire = createRequire(join(target, 'package.json'));
if (typeof runtimeRequire('dotenv').parse !== 'function')
throw new Error('Runtime dotenv is unavailable');
async function matchingFiles(directory, pattern) {
const found = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory())
found.push(...await matchingFiles(path, pattern));
else if (entry.isFile() && pattern.test(entry.name))
found.push(path);
}
return found;
}
async function verifyELF(path) {
const file = await open(path, 'r');
const header = Buffer.alloc(64);
try {
await file.read(header, 0, header.length, 0);
}
finally {
await file.close();
}
if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) ||
header[4] !== 2 || header[5] !== 1 || header.readUInt16LE(18) !== (arch === 'x64' ? 62 : 183)) {
throw new Error(`Native runtime is not a Linux ${arch} ELF binary: ${path}`);
}
}
// These public target details describe the package; environment values and credentials are never copied here.
await writeFile(join(target, 'runtime-target.json'), JSON.stringify({ platform: 'linux', arch, libc, packaging: 'standalone' }, null, 2) + '\n');
console.log(`Linux runtime ready: ${target}`);
+88
View File
@@ -0,0 +1,88 @@
// Read-only business smoke checks; POSTs only establish a guest or validate empty login input.
// Cookies stay in memory and are never printed or written to disk.
import assert from "node:assert/strict";
const base = process.env.PREVIEW_URL || "http://127.0.0.1:21021";
const publicOnly = process.env.SMOKE_PUBLIC_ONLY === "1";
const cookies = new Map();
const cookieHeader=()=>[...cookies].map(([key,value])=>`${key}=${value}`).join('; ');
async function request(path, body) {
const response = await fetch(`${base}/api/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {Cookie: cookieHeader(), ...(body === undefined ? {} : {"Content-Type":"application/json"})},
body: body === undefined ? undefined : JSON.stringify(body),
});
for (const item of response.headers.getSetCookie()) {
const [pair] = item.split(";"); const index = pair.indexOf("=");
cookies.set(pair.slice(0,index), pair.slice(index+1));
}
const envelope = await response.json();
assert.equal(response.status,200,`${path}: HTTP ${response.status}`);
assert.equal(envelope.code,200,`${path}: ${envelope.msg || envelope.tip}`);
return envelope.data;
}
const home = await request("playlet/home");
const response = await fetch(base);
const html = await response.text();
assert.equal(response.status,200);
assert.ok(!html.includes("data-must-watch-sort"),"首页筛选项应保持移除");
if (!publicOnly) {
const guest = await request("auth/guest",{});
assert.ok(guest.userId,"游客登录应返回用户 ID");
assert.equal(guest.token,undefined,"token 不应暴露给浏览器脚本");
assert.ok(cookies.has("drama_session"),"应通过 Cookie 保持会话");
console.log("PASS: HttpOnly guest session");
}
console.log("PASS: SSR homepage");
const checks = ["config/info", "config/footer/info", "navigation/header", "translation/category/list", "theme/list", "playlet/tag/options", "playlet/all/list?sortType=1&pageNum=1&pageSize=4", "playlet/hot/list?pageNum=1&pageSize=4", ...[1,2,3].map(rank=>`playlet/hot/rank/list?rankType=${rank}&pageNum=1&pageSize=4`), "playlet/topic/list?pageNum=1&pageSize=4", "applist/list?categoryId=0", "recharge/package/list", "playlet/hot-like", ...(!publicOnly?["auth/getInfo", "user/center/data", "playlet/history/list?pageNum=1&pageSize=4", "playlet/collect/list?pageNum=1&pageSize=4&watchStatus=0", "playlet/bought/list?pageNum=1&pageSize=4", "coin/trans/list?page=1&pageSize=10&type=0"]:[])];
const dataByPath=new Map();
for(let offset=0;offset<checks.length;offset+=4)await Promise.all(checks.slice(offset,offset+4).map(async path=>{const data=await request(path);assert.ok(data,`${path}: missing data`);dataByPath.set(path.split('?')[0],data);console.log(`PASS: ${path.split("?")[0]}`);}));
const pages=['/shorts/','/shorts/page/2/','/rank/hot/','/rank/new/','/rank/follow/','/topics/','/apps/','/home-road/','/navigation/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/','/info/feedback/'];
const topic=dataByPath.get('playlet/topic/list')?.list?.[0];
if(topic){
const detail=await request(`playlet/topic/detail?id=${topic.id}&pageNum=1&pageSize=4`);
assert.equal(detail.id,topic.id);assert.equal(typeof detail.name,'string');assert.ok(Array.isArray(detail.list));
pages.push(`/topics/${topic.id}/`,`/theme/${topic.id}/`);
}
const tag=dataByPath.get('playlet/tag/options')?.list?.[0];
if(tag)pages.push(`/tag/${tag.id}/`);
const block=home.homeConfigData?.find(item=>item.status&&!item.moreUrl);
if(block)pages.push(`/config/${block.id}/`);
const first = home.homeBannerData?.[0] || home.homeConfigData?.find(block=>block.list?.length)?.list[0];
if (first) {
const search=await request("playlet/search?"+new URLSearchParams({keyword:first.playName||first.title,pageNum:"1",pageSize:"4"}));
assert.ok(Array.isArray(search.list));
pages.push('/search/'+encodeURIComponent(first.playName||first.title)+'/');
const suggestions=await request('playlet/search/suggest?'+new URLSearchParams({keyword:first.playName||first.title}));assert.ok(Array.isArray(suggestions.list));
if (!publicOnly) {
const detail=await request(`playlet/detail?id=${first.id}`);
assert.equal(detail.id,first.id);
const chapters=await request(`playlet/chapter/list?playletId=${first.id}`);
const related=await request(`playlet/guess-like/list?playletId=${first.id}&pageNum=1&pageSize=6`);assert.ok(Array.isArray(related.list));
pages.push(`/detail/${first.id}/`);
if (chapters.list?.length) {
const chapter=await request(`playlet/chapter/detail?playletId=${first.id}&chapterId=${chapters.list[0].id}`);
assert.equal(chapter.id,chapters.list[0].id);
assert.equal(typeof chapter.canWatch,"boolean");
pages.push(`/play/${first.id}/${chapter.id}/`);
}
console.log("PASS: drama detail and chapter permissions");
}
console.log("PASS: search");
}
if(!publicOnly)pages.push('/member/center/','/member/favorites/','/member/history/','/member/purchased/','/member/settings/','/info/deposit/','/info/deposit/vip/');
for(let offset=0;offset<pages.length;offset+=3)await Promise.all(pages.slice(offset,offset+3).map(async path=>{
const response=await fetch(base+path,{headers:{Cookie:cookieHeader()},redirect:'manual'}),html=await response.text();
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null,path);assert.ok(!html.includes('内容暂不可用,请稍后重试'),path);assert.ok(!html.includes('class="api-loading"'),path);
const marker=path.startsWith('/member/settings/')?'member-settings':path.startsWith('/member/')?'member-center':path.startsWith('/info/deposit/')?'wallet-page':path==='/info/feedback/'?'id="feedback-form"':path==='/home-road/'?'return-home-directory':path.startsWith('/search/')?'data-search-results':'';
if(marker)assert.ok(html.includes(marker),path+' must contain the complete server-rendered page');
console.log(`PASS: page ${path}`);
}));
const rejected=await fetch(`${base}/api/not-allowed`);
assert.equal(rejected.status,404);
const invalidLogin=await fetch(`${base}/api/auth/login`,{method:"POST",headers:{Origin:new URL(base).origin,"Sec-Fetch-Site":"same-origin","Content-Type":"application/json"},body:"{}"});
assert.equal(invalidLogin.status,400);
assert.equal((await invalidLogin.json()).code,400);
console.log("PASS: proxy endpoint allowlist and browser login input validation");