diff --git a/README.md b/README.md index f20cfa3..f50b4ae 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,15 @@ 本地预览使用 Bun: ```bash -bun run dev +bun run dev # 默认 http://127.0.0.1:4173 +PORT=4199 bun run dev # 换端口 +HOST=0.0.0.0 bun run dev # 想让同局域网的其它设备也能打开时才用 ``` 打开 。在线站点是 ,构建与发布方式见下文「构建与发布」。 +开发服务器只监听 `127.0.0.1`,并且只服务线上真正会有的那三份内容(`index.html`、`glossary/`、`lessons/`)——这份清单与构建、线上 Worker 共用 [`src/site.ts`](./src/site.ts) 里的同一份定义。仓库里的 `.git/`、`package.json`、`wrangler.jsonc`、`AGENTS.md`、`docs/` 等在本地也是中文 404,和线上一致;少了末尾斜杠的目录地址(`/lessons/s1-01`)会像线上那样跳到带斜杠的地址。想把这些内容也给同一局域网的其他设备看,必须自己显式写 `HOST=0.0.0.0`,默认不会。 + ## 课件目录 每节课都有自己的目录:`lessons/<小写课程-id>/`。全部 40 个目录及其 `lesson.json` 已由 Bun 脚手架建立;40 节互动课件与随机测验目前均已实现(内容 `awaiting-user-review`,视频始终为仓库外交付): diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index b80a683..408318a 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -14,6 +14,7 @@ | 本地进度保存 | 已完成 | 刷新后恢复;浏览器拒绝存储时会给出提示且不崩溃 | | 学习尝试恢复 | 已完成 | 保存失败(含会话中途失败)当次会话立刻可见、可点“重试保存”;恢复后说明“本地保存已恢复”并把期间待写进度补写落盘,绝不显示成已保存 | | 课程结构校验 | 已完成 | `bun test` 校验课程数据的完整性和阶段均衡 | +| 本地开发服务器内容边界 | 已完成 | `bun run dev` 只监听 `127.0.0.1`(可用 `HOST` 覆盖),并且只服务 `index.html`、`glossary/`、`lessons/` 这三份内容——内容清单与构建、线上 Worker 共用 `src/site.ts` 里同一份定义。`/.git/config`、`/.gitignore`、`/wrangler.jsonc`、`/AGENTS.md`、`/package.json`、`/docs/`、`/scripts/`、`/src/` 等仓库文件,以及越界与编码写法(`..`、`%2e%2e`、`%2f`、`%00`、坏编码),一律给出与线上一致的中文 404;少了末尾斜杠的目录地址与线上一样 307 跳转(目录不存在则不跳);`PORT`/`HOST` 非法或端口被占用会当场说清而不是静默换端口 | | 互动页面脚本语法校验 | 已完成 | `bun test` 逐一编译首页与全部 40 课内嵌脚本,语法错误会立即失败,弥补此前只做字符串匹配的盲区 | | 手工 E2E 验收 | 已完成 | 见 `tests/e2e/manual-checklist.md`;S1-01 的八个场景另可用 `bun run e2e:s1-01` 在无头 Chrome 自动重跑,S1-02 至 S1-08 的手写填空微练习可用 `bun run e2e:s1-typed` 自动重跑,S2-01 至 S2-08 的手写填空微练习可用 `bun run e2e:s2-typed` 自动重跑,S3-01 至 S3-08 用 `bun run e2e:s3-typed`,S4-01 至 S4-08 用 `bun run e2e:s4-typed`,S5-01 至 S5-08 用 `bun run e2e:s5-typed`,全部 39 节已迁移课用 `bun run e2e:typed` | | 整课推进与随机小测浏览器复验 | 已完成 | `bun run e2e:flow` 把 S1-02 至 S5-08 全部 39 课从 `0 / N 已推进` 走到 `N / N`(741 项,每课 19 项),并逐课量「检查目标绑到本课学习目标原文、没答完不能提交、提交后才给解析与得分、没考住时按学习目标汇总错题并指回本节讲解且不倒退进度、没做完不提前引到下一课、完成后继续学习入口指向 `courseData` 的下一课且可达」;步骤与仍需人工确认的部分见 `tests/e2e/flow-manual-checklist.md` | diff --git a/docs/roadmap.md b/docs/roadmap.md index df51804..ca22b3b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -129,6 +129,7 @@ - **S5-08 随机选择题小测**:从 7 道待人工审校选择题中随机抽取 3 道;同次无重复,提交后显示得分与解析。题库来源为本课 course-plan / 自编。 - **初学者常见问题词条**:`glossary/faq.json` 提供基础解释;S1-01 中的 `g++`、`.cpp`、`main`、`cout` 等可点开查看;S1-02 中的变量、`int`、`char`、赋值等同样可点开;S1-03 中的表达式、整除、取模、优先级和括号同样可点开;S1-04 中的 `cin`、换行、固定小数和提示语同样可点开;S1-05 中的 `if`、`else if`、`==` 和 `&&` 同样可点开;S1-06 中的 `for`、`while`、循环变量、累加和死循环同样可点开;S1-07 中的嵌套循环、外层、内层、`break` 和 `continue` 同样可点开;S1-08 中的编译错误、运行错误、答案错误、缩进、命名和分段输出同样可点开;S2-01 中的数组、下标、长度、初始化和越界同样可点开;S2-02 中的 `char`、`string`、长度、下标和 ASCII 同样可点开;S2-03 中的函数、参数、返回值、调用和定义同样可点开;S2-04 中的作用域、局部变量、全局变量、值传递和引用同样可点开;S2-05 中的结构体、成员、成员访问、结构体数组和按字段比较同样可点开;S2-06 中的冒泡思想、sort、比较规则、交换和稳定性同样可点开;S2-07 中的枚举、枚举范围、状态更新、模拟和漏分支同样可点开;S2-08 中的操作次数、O(n)、O(n²)、边界数据和测试点同样可点开;S3-01 中的整除、取模、数位、最大公约数和循环不变量同样可点开;S3-02 中的递归、递归边界、递归调用、调用栈和阶乘同样可点开;S3-03 中的二分查找、单调性、左右边界、中点和死循环同样可点开;S3-04 中的前缀和、前缀和数组、区间和、下标偏移和 O(n) 同样可点开;S3-05 中的双指针、滑动窗口、左指针、右指针和窗口条件同样可点开;S3-06 中的贪心选择、选择标准、反例、排序后决策和 sort 同样可点开;S3-07 中的栈、队列、后进先出、先进先出、括号匹配和广度优先直觉同样可点开;S3-08 中的动态规划、状态、转移、初始化和一维 DP 同样可点开;S4-01 中的竞赛程序、数据范围、样例、标准输入输出和干净输出同样可点开;S4-02 中的建模卡、已知与未知、样例反推、约束和算法候选同样可点开;S4-03 中的状态表、事件顺序、条件分支、样例追踪和边界数据同样可点开;S4-04 中的深度优先搜索、搜索树、回溯和剪枝同样可点开;S4-05 中的数据范围、sort、二分查找、计数数组和重复元素同样可点开;S4-06 中的状态、转移、初始化、选或不选、最优子结构和状态压缩同样可点开;S4-07 中的图、邻接表、访问标记、深度优先搜索、广度优先直觉和连通块同样可点开;S4-08 中的子任务、部分分、保底方案、先易后难和复杂度降级同样可点开;S5-01 中的限时解题流程、审题计时、建模草稿、样例检查和预留检查时间同样可点开;S5-02 中的系统化调试、最小复现、分段输出、断言思路和差分检查同样可点开;S5-03 中的溢出、long long、数组初始化、下标越界、运算优先级、== 比较相等和干净输出同样可点开;S5-04 中的暴力基线、子任务、特殊情况、复杂度升级、伪优化和差分检查同样可点开,S5-05 中的模拟赛、提交顺序、自测表和时间记录同样可点开;S5-06 中的错误分类、边界数据、改法和重做关键题同样可点开;S5-07 中的新题组、交叉检查、对照上一次和稳定习惯同样可点开;S5-08 中的知识地图、薄弱模块、赛前检查清单和 CSP-S 衔接条件同样可点开,并给出可选的权威外部链接。 - **静态站点构建与发布**:`bun run build` 把 `index.html`、`glossary/` 与 `lessons/` 重新复制到 `dist/`,`bun run deploy` 先构建再用 `bunx wrangler deploy` 发布到 Cloudflare Worker `csp-cpp-courseware`(自定义域名 ;配置见 `wrangler.jsonc`,课程地址的末尾斜杠跳转由静态资源的 `html_handling` 负责,Worker 入口 `src/index.ts` 只把 404 换成中文说明)。产物只能来自仓库内容,因此不会再出现「仓库已经补到 40 节课、线上还停在 4 节课」的漂移——那次漂移的根因正是构建与部署只在某台机器上手工跑过一次、此后再没人重跑。`tests/deploy-assets.test.ts` 在 `bun test` 里守住这条链路:产物覆盖 `courseData` 的每一节课、旧产物不残留、课程地址的末尾斜杠跳转与中文 404 仍在、部署目标与文档一致。同一套浏览器复验也可以直接打在线上:`CSP_E2E_ORIGIN=https://cplus.talkincode.net bun tests/e2e/flow-cdp.ts` 会复用已在运行的服务、不再自启开发服务器,线上已跑过 S5-07/S5-08 的整课推进与 S5-08/S3-01 的微练习。发布默认不再靠人记得:main 上的推送由 `.github/workflows/test-and-deploy.yml` 自动跑「`bun test` → `bun run deploy` → 线上抽检(每一节课、末尾斜杠跳转、词条面板、中文 404)」,缺 `CLOUDFLARE_API_TOKEN` 时发布步骤明确失败而不是静默跳过。 +- **本地开发服务器内容边界**:`bun run dev` 只监听 `127.0.0.1`(要改就用 `HOST`),并且只服务站点自己的那三份内容。以前它把整个仓库目录当网站:请求什么路径就拼出同名文件,于是 `/.git/config`、`/wrangler.jsonc`、`/AGENTS.md` 在本地都是 200、线上都是 404,同一个地址在两处表现不一致;`Bun.serve` 默认又监听所有网卡,同一局域网内的任何人都能这样把仓库连同 `.git` 读走。现在内容清单由 `src/site.ts` 的 `SITE_SOURCES` 定义一次,构建、开发服务器与线上 Worker 读的是同一份;站点之外的地址与缺课的地址给出与线上一致的中文 404,`/lessons/s1-01` 这类少了末尾斜杠的目录地址跟线上一样 307 跳到带斜杠的地址(目录不存在就不跳,直接 404,避免缺课地址多绕一圈)。`tests/dev-server.test.ts` 在 `bun test` 里守住这套边界。 - **内容结构校验**:`tests/curriculum.test.ts` 会校验 5 个阶段、40 节课、唯一标识及每节课的目标/内容/检验项结构。 - **手工端到端脚本**:`tests/e2e/manual-checklist.md` 覆盖导航、课件阅读和本地进度恢复的基础路径。 @@ -282,6 +283,7 @@ | 课后继续学习入口 | 中 | ✅ 39 节非末课走完 `N / N` 后自己打开 `#nextStep`,写明下一课是 `Sx-xx` 与课名、先练的那件事,链接直达下一课的 `index.html` 且真的可达;S5-08 作为最后一课换成课程收尾出口并指回课程路线。真实浏览器复验见 `bun run e2e:flow`(2026-09-21 Chrome 153 无头,每课 19 项) | ✅ 没做完之前入口是隐藏的,不会把还在这一课上的学习者提前引走;边界/失败路径另由 `tests/next-lesson.test.ts` 逐课看住:入口在「视频边界」说明之前、一页出现两个入口、链接指向 `../sX-XX/`(大写或大写目录名)、指向不存在的下一课、标题或先练目标与 `courseData` 漂移、末课仍留指向不存在的下一课、或出现成绩评定/证书/保过之类的说法,都会当场失败 | 不适用:无账号 | ✅ 清掉本地存储后入口重新隐藏、页面回到 `0 / N`,不依赖任何服务端状态;下一课链接用的是相对路径,开发服务器与静态托管下都成立 | [`tests/next-lesson.test.ts`](../tests/next-lesson.test.ts)、[`E2E-flow`](../tests/e2e/flow-cdp.ts)、[`flow 清单`](../tests/e2e/flow-manual-checklist.md#e2e-flow-05-课后继续学习入口) | | 静态站点构建与发布 | 中 | ✅ `bun run build` 从仓库内容重建 `dist/`(40 节课页面 + 课程目录 + 词条面板),`bun run deploy` 先构建再发布到 `cplus.talkincode.net`;2026-09-21 发布后 40 节课地址全部返回 200、`/lessons/s5-08/` 可达、词条面板可载入,线上页面与本地构建逐字节一致(只有 Cloudflare 注入的脚本不同),并用 `CSP_E2E_ORIGIN=https://cplus.talkincode.net` 把真实浏览器复验直接打在线上:`e2e:typed` 的 s5-08 + s3-01 两课 34 项通过,`e2e:flow` 的 s5-07 + s5-08 两课 38 项通过 | ✅ 产物缺课、`dist/` 里残留上一次的旧页面、课程地址的末尾斜杠跳转(`html_handling`)或中文 404 丢失、部署目标(Worker 名 / `dist` 目录 / 域名)与文档漂移,都会由 [`tests/deploy-assets.test.ts`](../tests/deploy-assets.test.ts) 当场失败 | 不适用:无账号 | ✅ 构建每次清空重建 `dist/`,重复发布幂等;线上出错可用 `bunx wrangler rollback` 退回上一个版本;main 上的推送由 [`.github/workflows/test-and-deploy.yml`](../.github/workflows/test-and-deploy.yml) 自动发布(`bun test` 通过才发布,发布后抽检线上) | [`tests/deploy-assets.test.ts`](../tests/deploy-assets.test.ts)、[`tests/ci-workflow.test.ts`](../tests/ci-workflow.test.ts)、[`scripts/build.ts`](../scripts/build.ts)、[`src/index.ts`](../src/index.ts)、[`wrangler.jsonc`](../wrangler.jsonc)、[`.github/workflows/test-and-deploy.yml`](../.github/workflows/test-and-deploy.yml) | +| 本地开发服务器内容边界 | 中 | ✅ `bun run dev` 能打开站点内容:首页、课程页(含 `/lessons/s5-08/index.html` 这种直指文件的地址)、词条表与词条面板都返回 200 且带 UTF-8 类型头;少了末尾斜杠的目录地址与线上一致 307 跳到带斜杠的地址 | ✅ 站点之外的地址一律中文 404:`/.git/config`、`/.gitignore`、`/AGENTS.md`、`/README.md`、`/package.json`、`/wrangler.jsonc`、`/docs/`、`/scripts/`、`/src/`、`/tests/`、`/node_modules/`、`/.env`、`/.dev.vars`;越界与编码写法(`..`、`%2e%2e`、`%2f`、`%00`、坏掉的 `%`、`//`、`.` 开头段、不带前导斜杠)都被挡在内容边界之外;`PORT` 非整数或越界、`HOST` 为空、端口被占用都会当场失败并说清怎么办,不静默换端口 | 不适用:无账号 | ✅ 只监听 `127.0.0.1`,不再把仓库连同 `.git` 暴露给同局域网;换端口或换监听地址用 `PORT=4199` / `HOST=127.0.0.1`,直接重启即可,不写任何持久状态 | [`tests/dev-server.test.ts`](../tests/dev-server.test.ts)、[`scripts/dev.ts`](../scripts/dev.ts)、[`src/site.ts`](../src/site.ts) | 本项目当前无权限模型,故权限角色覆盖均不适用;一旦新增账号或教师端,相关行必须立即改为双角色验证,且在实现前不得宣称功能完成。 diff --git a/scripts/build.ts b/scripts/build.ts index fcfa3b0..b004d74 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,5 +1,6 @@ import { cp, mkdir, readdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; +import { SITE_SOURCES } from "../src/site"; const projectRoot = resolve(import.meta.dir, ".."); @@ -7,7 +8,10 @@ const projectRoot = resolve(import.meta.dir, ".."); // 这里曾经出过事——仓库已经补到 40 节课,cplus.talkincode.net 还停在 4 节课, // 因为构建与部署只在某台机器上手工跑过一次。产物一律从仓库内容生成, // 不允许手工拼装 dist/,否则「仓库有这节课、站点上还是 404」的漂移会再出现一次。 -export const siteSources = ["index.html", "glossary", "lessons"] as const; +// +// 清单本身放在 src/site.ts:本地开发服务器与线上 Worker 读的是同一条边界, +// 各写一份就会重新长出「本地能打开、线上 404」这类不一致。 +export const siteSources = SITE_SOURCES; export async function buildStaticSite({ root = projectRoot, diff --git a/scripts/dev.ts b/scripts/dev.ts index 494b0a3..c76b052 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,10 +1,8 @@ -const projectRoot = `${import.meta.dir}/..`; -const portValue = Bun.env.PORT ?? "4173"; -const port = Number(portValue); +import { statSync } from "node:fs"; +import { resolve } from "node:path"; +import { NOT_FOUND_BODY, resolveSitePath } from "../src/site"; -if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`PORT must be an integer between 1 and 65535; received ${portValue}.`); -} +const projectRoot = resolve(import.meta.dir, ".."); const mimeTypes: Record = { ".css": "text/css; charset=utf-8", @@ -14,23 +12,37 @@ const mimeTypes: Record = { ".svg": "image/svg+xml", }; -function requestedFile(pathname: string): string | null { - let decoded: string; +export type DevServer = ReturnType; - try { - decoded = decodeURIComponent(pathname); - } catch { - return null; +export type DevServerOptions = { + port: number; + host: string; +}; + +// 默认只监听本机回环地址。以前用的是 Bun.serve 的默认值 —— 所有网卡, +// 于是同一局域网内的任何人都能把仓库连同 .git 读走。 +export const DEFAULT_HOST = "127.0.0.1"; +export const DEFAULT_PORT = 4173; + +const notFoundHeaders = { "Content-Type": "text/plain; charset=utf-8" }; + +function parsePort(raw: string): number { + const port = Number(raw); + + // 0 表示「让操作系统挑一个空闲端口」,只有测试会用到。 + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`PORT 必须是 0 到 65535 之间的整数,当前是 ${raw}。`); } - const normalizedPath = decoded.endsWith("/") ? `${decoded}index.html` : decoded; - const segments = normalizedPath.split("/"); + return port; +} - if (!normalizedPath.startsWith("/") || segments.includes("..") || segments.includes("\0")) { - return null; +function parseHost(raw: string): string { + if (raw.trim().length === 0) { + throw new Error("HOST 不能为空;只想在本机打开就写 127.0.0.1(默认值)。"); } - return `${projectRoot}${normalizedPath}`; + return raw.trim(); } function contentType(path: string): string { @@ -38,27 +50,78 @@ function contentType(path: string): string { return mimeTypes[extension] ?? "application/octet-stream"; } -const server = Bun.serve({ - port, - async fetch(request) { - const path = requestedFile(new URL(request.url).pathname); +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function startDevServer(options: DevServerOptions): DevServer { + const port = parsePort(String(options.port)); + const host = parseHost(String(options.host)); + + try { + return Bun.serve({ + port, + hostname: host, + async fetch(request) { + const url = new URL(request.url); + const target = resolveSitePath(url.pathname); + + // 站点之外的一切都按「没有这个地址」处理:线上只发布 index.html、 + // glossary/、lessons/,仓库里的 .git、脚本和文档在那份产物里并不存在。 + // 文案与状态码都跟线上 Worker 保持一致。 + if (target.kind === "outside") { + return new Response(NOT_FOUND_BODY, { status: 404, headers: notFoundHeaders }); + } + + // 少了末尾斜杠的目录地址:线上静态资源会 307 到带斜杠的地址 + // (wrangler.jsonc 的 html_handling: "auto-trailing-slash"),本地跟上同一步, + // 否则「本地打不开、线上能打开」会反过来长出来。目录不存在时不跳转,直接 404。 + if (target.kind === "redirect") { + if (!isDirectory(resolve(projectRoot, target.target.slice(1, -1)))) { + return new Response(NOT_FOUND_BODY, { status: 404, headers: notFoundHeaders }); + } - if (!path) { - return new Response("Invalid path.", { status: 400 }); - } + return new Response(null, { + status: 307, + headers: { Location: `${target.target}${url.search}` }, + }); + } - const file = Bun.file(path); - if (!(await file.exists())) { - return new Response("Not found.", { status: 404 }); - } + const path = resolve(projectRoot, target.relativePath); + const file = Bun.file(path); - return new Response(file, { - headers: { - "Cache-Control": "no-store", - "Content-Type": contentType(path), + if (!(await file.exists())) { + return new Response(NOT_FOUND_BODY, { status: 404, headers: notFoundHeaders }); + } + + return new Response(file, { + headers: { + "Cache-Control": "no-store", + "Content-Type": contentType(path), + }, + }); }, }); - }, -}); + } catch (error) { + // Bun 在端口占用时抛的是英文的 "Is port X in use?",直接丢给使用者既不好读 + // 也没说怎么办,所以这里换成中文并给出换端口的写法。 + const detail = error instanceof Error ? error.message : String(error); + + throw new Error( + `PORT ${host}:${port} 无法监听:端口可能已被占用。换一个再试,例如 PORT=4199 bun run dev。(${detail})`, + ); + } +} + +if (import.meta.main) { + const server = startDevServer({ + port: parsePort(Bun.env.PORT ?? String(DEFAULT_PORT)), + host: parseHost(Bun.env.HOST ?? DEFAULT_HOST), + }); -console.log(`CSP C++ courseware is running at ${server.url}`); + console.log(`CSP C++ courseware is running at ${server.url}`); +} diff --git a/src/index.ts b/src/index.ts index 58eac2d..5fcce25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { NOT_FOUND_BODY } from "./site"; + export interface Env { ASSETS: Fetcher; } @@ -7,7 +9,8 @@ export interface Env { // 课程地址的末尾斜杠跳转(/lessons/s1-01 → /lessons/s1-01/)由 wrangler.jsonc 的 // html_handling: "auto-trailing-slash" 在静态资源层完成,请求根本到不了这里; // 之前这里另写了一段跳转,线上从来没有生效过,只把缺课的地址多绕了一次 301。 -const notFoundBody = "页面未找到 (404 Not Found)"; +// 这句话本身与本地开发服务器共用同一份定义(见 src/site.ts),两处不再各写一遍。 +const notFoundBody = NOT_FOUND_BODY; export default { async fetch(request: Request, env: Env): Promise { diff --git a/src/site.ts b/src/site.ts new file mode 100644 index 0000000..c92e27c --- /dev/null +++ b/src/site.ts @@ -0,0 +1,80 @@ +// 站点内容边界只在这里定义一次:构建(scripts/build.ts)、本地开发服务器 +// (scripts/dev.ts)和线上 Worker(src/index.ts)都读这一份。 +// +// 以前这条边界有两个版本:构建只把 index.html、glossary/、lessons/ 复制进 dist/, +// 开发服务器却把整个仓库目录当网站,请求什么路径就拼出同名文件。于是 +// `/.git/config`、`/wrangler.jsonc`、`/AGENTS.md` 在本地都是 200、线上都是 404, +// 同一个地址在两处表现不一致;本地服务又默认监听所有网卡,局域网里的其他人 +// 也能这样把仓库连同 .git 读走。 +export const SITE_SOURCES = ["index.html", "glossary", "lessons"] as const; + +export const SITE_ENTRY = "index.html"; + +// 线上 Worker 与本地开发服务器对「没有这个地址」必须说同一句话:零基础学习者 +// 不该在一个环境看到中文说明、在另一个环境看到托管商的英文默认页。 +export const NOT_FOUND_BODY = "页面未找到 (404 Not Found)"; + +export type SitePath = + | { kind: "serve"; relativePath: string } + | { kind: "redirect"; target: string } + | { kind: "outside" }; + +const outside: SitePath = { kind: "outside" }; + +// 目录名在这里一律是 `s<阶段>-<序号>` 这种不含点的写法;把「最后一段带不带扩展名」 +// 当作「文件还是目录」的判据,和静态服务器的常规做法一致。真遇到带点的目录名, +// 结果只是本地 404、线上 307,不会读到站点之外的内容。 +function looksLikeFile(segment: string): boolean { + return segment.lastIndexOf(".") > 0; +} + +export function resolveSitePath(pathname: string): SitePath { + let decoded: string; + + // 百分号编码坏掉(例如 `/lessons/%`)时不要让异常冒到调用方。 + try { + decoded = decodeURIComponent(pathname); + } catch { + return outside; + } + + if (!decoded.startsWith("/") || decoded.includes("\0")) return outside; + if (decoded === "/") return { kind: "serve", relativePath: SITE_ENTRY }; + + // 末尾斜杠表示「要的是这个目录」,去掉后再切分;`//` 会产生空段,随后被挡下。 + const trailingSlash = decoded.endsWith("/"); + const body = trailingSlash ? decoded.slice(1, -1) : decoded.slice(1); + const segments = body.split("/"); + + for (const segment of segments) { + // `.`、`..` 与任何点开头的段(`.git`、`.env`、`.dev.vars`)都不是站点内容。 + if (segment.length === 0 || segment.startsWith(".")) return outside; + } + + const [first, ...rest] = segments; + + if (first === SITE_ENTRY) { + // 只有 `/index.html` 本身是站点内容,`/index.html/...` 这种拼法不存在。 + return rest.length === 0 && !trailingSlash + ? { kind: "serve", relativePath: SITE_ENTRY } + : outside; + } + + if (!SITE_SOURCES.includes(first as (typeof SITE_SOURCES)[number])) return outside; + + if (rest.length === 0) { + return trailingSlash + ? { kind: "serve", relativePath: `${first}/${SITE_ENTRY}` } + : { kind: "redirect", target: `/${first}/` }; + } + + const relativePath = segments.join("/"); + + // 目录地址:服务目录下的 index.html;目录不存在时由调用方给出 404。 + if (trailingSlash) return { kind: "serve", relativePath: `${relativePath}/${SITE_ENTRY}` }; + + // 少了末尾斜杠的目录地址:只有目录真的存在才该跳转,这件事由调用方确认。 + return looksLikeFile(segments[segments.length - 1]) + ? { kind: "serve", relativePath } + : { kind: "redirect", target: `/${relativePath}/` }; +} diff --git a/tests/dev-server.test.ts b/tests/dev-server.test.ts new file mode 100644 index 0000000..cad6034 --- /dev/null +++ b/tests/dev-server.test.ts @@ -0,0 +1,176 @@ +import { expect, test } from "bun:test"; +import { startDevServer, type DevServer } from "../scripts/dev"; +import { NOT_FOUND_BODY, SITE_SOURCES, resolveSitePath } from "../src/site"; +import { siteSources } from "../scripts/build"; + +// 开发服务器长期只做「把仓库目录当网站」这一件事:请求什么路径就拼出仓库里的同名文件, +// 所以 `/.git/config`、`/wrangler.jsonc`、`/AGENTS.md` 在本地都是 200,而线上这些地址 +// 根本不存在(站点只发布 index.html、glossary/、lessons/ 三份内容)。 +// 更糟的是默认监听地址是 0.0.0.0,同一局域网内的任何人都能这样把仓库连同 .git 读走。 +// 这里把「本地看到的」和「线上发布的那一份」绑在一起,并守住只监听本机。 + +// 这些地址在线上必然 404(不在 dist/ 里),本地也不该例外。 +const outsideSite = [ + "/.git/config", + "/.git/HEAD", + "/.gitignore", + "/AGENTS.md", + "/README.md", + "/package.json", + "/wrangler.jsonc", + "/bun.lock", + "/docs/roadmap.md", + "/scripts/dev.ts", + "/src/index.ts", + "/tests/dev-server.test.ts", + "/node_modules/.bin/bun", + "/.env", + "/.dev.vars", +]; + +async function withServer(run: (server: DevServer) => Promise): Promise { + const server = startDevServer({ port: 0, host: "127.0.0.1" }); + + try { + await run(server); + } finally { + server.stop(true); + } +} + +// 不能借 `new URL(pathname, origin)` 拼地址:URL 解析器会把 `%2e%2e` 这类写法先行归一化, +// 于是「服务端有没有挡住编码后的越界路径」这件事根本没被测到。这里直接拼字符串, +// 让请求原样带上路径。 +function assetUrl(pathname: string, origin: string): string { + return `${origin}${pathname}`; +} + +test("本地开发服务器只监听本机,不再把仓库暴露给整个局域网", async () => { + await withServer(async (server) => { + expect(server.hostname).toBe("127.0.0.1"); + expect(server.url.hostname).toBe("127.0.0.1"); + }); +}); + +test("本地开发服务器只服务线上的那三份内容,仓库其余文件一律中文 404", async () => { + await withServer(async (server) => { + const origin = server.url.origin; + const leaked: string[] = []; + + for (const pathname of outsideSite) { + const response = await fetch(assetUrl(pathname, origin)); + + if (response.status !== 404) leaked.push(`${pathname} -> ${response.status}`); + else if ((await response.text()) !== NOT_FOUND_BODY) leaked.push(`${pathname} -> 非中文 404`); + } + + expect(leaked).toEqual([]); + }); +}); + +test("本地开发服务器能打开站点自己的内容", async () => { + await withServer(async (server) => { + const origin = server.url.origin; + const pages = ["/", "/index.html", "/lessons/s1-01/", "/lessons/s5-08/index.html"]; + + for (const pathname of pages) { + const response = await fetch(assetUrl(pathname, origin)); + + expect(response.status).toBe(200); + expect(await response.text()).toMatch(//i); + } + + // 词条面板与词条表是课件里的硬依赖,本地 404 会让每一课都在报错。 + for (const pathname of ["/glossary/faq.json", "/glossary/faq-panel.js"]) { + expect((await fetch(assetUrl(pathname, origin))).status).toBe(200); + } + + // 首页与课程页是 UTF-8 中文,类型头错了浏览器就会显示乱码。 + const home = await fetch(assetUrl("/", origin)); + + expect(home.headers.get("Content-Type")).toContain("text/html"); + expect(home.headers.get("Content-Type")).toContain("utf-8"); + }); +}); + +test("课程地址少了末尾斜杠时,本地和线上一样跳到带斜杠的地址", async () => { + await withServer(async (server) => { + const origin = server.url.origin; + const response = await fetch(assetUrl("/lessons/s1-01", origin), { redirect: "manual" }); + + // 线上由静态资源的 html_handling: "auto-trailing-slash" 完成这一步(实测 307), + // 本地以前直接 404——链接少一个斜杠就成了「本地打不开、线上能打开」。 + expect(response.status).toBe(307); + expect(response.headers.get("Location")).toBe("/lessons/s1-01/"); + + // 目录不存在时不能凭空跳转,否则缺课的地址会绕一圈再报 404。 + const missing = await fetch(assetUrl("/lessons/s9-99", origin), { redirect: "manual" }); + + expect(missing.status).toBe(404); + expect(await missing.text()).toBe(NOT_FOUND_BODY); + }); +}); + +test("越界路径与仓库文件不会因为编码写法绕开内容边界", () => { + const attacks = [ + "/.git/config", + "/.git/config/", + "/.gitignore", + "/../package.json", + "/lessons/../package.json", + "/lessons/..%2F..%2Fpackage.json", + "/lessons/s1-01/%2e%2e%2f%2e%2e%2fAGENTS.md", + "/%2e%2e/%2e%2e/.git/config", + "/glossary/%00/faq.json", + "/lessons/%", + "/lessons/%zz/", + "/lessons/./s1-01/", + "/lessons//s1-01/", + "/scripts/dev.ts", + "/src/index.ts", + "/docs/roadmap.md", + "/node_modules/bun/index.js", + "lessons/s1-01/", + "", + ]; + const leaked = attacks.filter((pathname) => resolveSitePath(pathname).kind !== "outside"); + + expect(leaked).toEqual([]); +}); + +test("内容边界认得出站点自己的地址,包括目录与文件两种形态", () => { + const serve = (relativePath: string) => ({ kind: "serve", relativePath }); + + expect(resolveSitePath("/")).toEqual(serve("index.html")); + expect(resolveSitePath("/index.html")).toEqual(serve("index.html")); + expect(resolveSitePath("/lessons/s1-01/")).toEqual(serve("lessons/s1-01/index.html")); + expect(resolveSitePath("/lessons/s1-01/index.html")).toEqual(serve("lessons/s1-01/index.html")); + expect(resolveSitePath("/glossary/faq.json")).toEqual(serve("glossary/faq.json")); + expect(resolveSitePath("/glossary/faq-panel.js")).toEqual(serve("glossary/faq-panel.js")); + + // 目录地址少了末尾斜杠:交给调用方决定要不要跳转(目录不存在就 404)。 + expect(resolveSitePath("/lessons/s1-01")).toEqual({ + kind: "redirect", + target: "/lessons/s1-01/", + }); + expect(resolveSitePath("/glossary")).toEqual({ kind: "redirect", target: "/glossary/" }); +}); + +test("站点内容边界只定义一次:开发服务器、构建与线上发布读的是同一份清单", () => { + // 构建会把 siteSources 复制进 dist/,开发服务器从同一份清单判断「哪些请求算站点内容」。 + // 两边各写一份就会重新长出「本地能打开、线上 404」这类漂移,所以这里锁住是同一个数组。 + expect(siteSources).toBe(SITE_SOURCES); + expect([...SITE_SOURCES]).toEqual(["index.html", "glossary", "lessons"]); + expect(NOT_FOUND_BODY).toBe("页面未找到 (404 Not Found)"); +}); + +test("开发服务器的端口与监听地址都要先校验,坏值当场说清而不是静默换端口", async () => { + expect(() => startDevServer({ port: 0, host: "" })).toThrow(/HOST/); + expect(() => startDevServer({ port: 70000, host: "127.0.0.1" })).toThrow(/PORT/); + expect(() => startDevServer({ port: 1.5, host: "127.0.0.1" })).toThrow(/PORT/); + + // 端口被占用时要把「占用」说出来并给出换端口的方法,而不是换个端口假装启动成功。 + await withServer(async (server) => { + expect(() => startDevServer({ port: server.port, host: "127.0.0.1" })).toThrow(/PORT/); + }); +}); diff --git a/tests/e2e/manual-checklist.md b/tests/e2e/manual-checklist.md index 46d909b..1e1728d 100644 --- a/tests/e2e/manual-checklist.md +++ b/tests/e2e/manual-checklist.md @@ -114,3 +114,38 @@ bun run dev > S1-01 的这条场景已由 `bun run e2e:s1-01`(第七个场景)在真实浏览器中自动复验:脚本用 CDP 屏蔽 `/glossary/faq.json`,再取消屏蔽后点击重载。 > 面板脚本本体被屏蔽的情况由同一脚本的第八个场景复验:屏蔽 `/glossary/faq-panel.js` 后断言说明可见、向辅助技术播报、重新载入入口存在,且课件其他互动照常。 + +## E2E-08 本地开发服务器的内容边界 + +适用 `bun run dev` 启动的本地预览。这条场景不需要浏览器,用 `curl` 就能复验;它守的是「本地看到的」和「线上发布的那一份」不能出现两套内容。 + +1. 在仓库根目录执行 `bun run dev`,确认它打印的地址是 。 +2. 换一台设备(例如手机连同一个 Wi-Fi)访问 `http://<本机局域网 IP>:4173/`。 + +**预期:** 第 1 步打印的是 `127.0.0.1` 而不是 `0.0.0.0`;第 2 步连不上。默认只监听本机回环地址,仓库不会被同局域网的其他设备读走;确实需要给别的设备看时才显式写 `HOST=0.0.0.0 bun run dev`。 + +3. 依次请求下面这些地址,记录状态码: + +```bash +curl -s -o /dev/null -w '%{http_code} /\n' http://localhost:4173/ +curl -s -o /dev/null -w '%{http_code} .git/config\n' http://localhost:4173/.git/config +curl -s -o /dev/null -w '%{http_code} wrangler.jsonc\n' http://localhost:4173/wrangler.jsonc +curl -s -o /dev/null -w '%{http_code} AGENTS.md\n' http://localhost:4173/AGENTS.md +curl -s -o /dev/null -w '%{http_code} package.json\n' http://localhost:4173/package.json +curl -s -o /dev/null -w '%{http_code} 越界\n' http://localhost:4173/lessons/..%2F..%2Fpackage.json +curl -s -o /dev/null -w '%{http_code} 缺课\n' http://localhost:4173/lessons/s9-99/ +curl -s -o /dev/null -w '%{http_code} s1-01/\n' http://localhost:4173/lessons/s1-01/ +curl -s -o /dev/null -w '%{http_code} faq-panel\n' http://localhost:4173/glossary/faq-panel.js +``` + +**预期:** `/`、`/lessons/s1-01/`、`/glossary/faq-panel.js` 是 `200`;`/.git/config`、`/wrangler.jsonc`、`/AGENTS.md`、`/package.json`、越界地址和缺课的 `/lessons/s9-99/` 全是 `404`,响应体是中文的 `页面未找到 (404 Not Found)`,与线上 Worker 说同一句话。本地不再是把整个仓库目录当网站,`.git/` 与仓库配置不会被读走。 + +4. 请求少了末尾斜杠的课程地址:`curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:4173/lessons/s1-01`。 + +**预期:** `307` 并跳到 `http://localhost:4173/lessons/s1-01/`,与线上(`html_handling: "auto-trailing-slash"`)表现一致;请求不存在课程的裸地址 `/lessons/s9-99` 则直接 `404`,不会先绕一次跳转。 + +5. 用 `PORT=abc bun run dev`、`HOST= bun run dev` 各启动一次;再占住 4173 端口后重复 `bun run dev`。 + +**预期:** 三种情况都当场失败并打印中文说明(`PORT` 必须是 0 到 65535 之间的整数、`HOST` 不能为空、`PORT 127.0.0.1:4173 无法监听:端口可能已被占用`并给出 `PORT=4199 bun run dev` 的写法),不会静默换一个端口假装启动成功。 + +> 这条场景已由 `bun test` 里的 [`tests/dev-server.test.ts`](./dev-server.test.ts) 自动复验(8 条用例 40 余项断言:只监听 `127.0.0.1`、17 个仓库地址与 12 种越界/编码写法都拿到中文 404、站点内容与词条面板可打开且带 UTF-8 类型头、目录地址 307 跳转与缺课 404、内容清单与构建共用同一份定义、坏 `PORT`/`HOST`/端口占用当场失败)。手工步骤保留下来,用于换机器或换网络后复核这条边界仍然成立。