ComiCine
一个完全无服务器的个人网站与管理系统,运行在 comicine.com。公开页面、人类验证门禁、访客面板、Apple Account 管理器、监控与状态页全部运行在 Cloudflare 上(Pages Functions、Workers、D1、R2)。
功能特性
- 公开页面:首页、服务、联系、作品集 —— 以 4 种语言预渲染,支持深色/浅色主题切换
- 人类验证:Turnstile 门禁页在所有主机上都原地渲染在请求的 URL 上(不跨域跳转)—— 见 门禁行为
- 访客面板(
visitor.comicine.com):Pages Function + D1 分析;显示经过哈希处理的访客标识(列表接口不返回原始 IP)、国家/地区、可获取时的 ISP/ASN、状态码分布,并受登录保护 - 首次加载之后,访客状态筛选标签在本地过滤,不再发起新的
/api/visitors请求;API 仍然接受?status=<3-digit>以支持深链接 - Apple Account 管理器(
account.comicine.com/apple):Pages Function + D1 增删改查,支持拖拽排序;密码使用 Fernet 加密存储,列表中只显示掩码后的密码,并支持临时明文查看 - 认证与角色:单一
ComiCineAccess 应用保护identity.comicine.com/login;identity 主机上的 Pages Function 验证 JWT 后,通过一次性 D1 交接把身份和来源主机交回来源站点,再由该站点按本地配置授权。目前两个受保护站点都需要 admin 会话。 - 共享登录跳转:未登录浏览器打开受保护页面时,站点先返回 HTTP 401 HTML,再由同源外置脚本跳转到
identity.comicine.com/login;Cloudflare Access 随后引导浏览器经limooo.cloudflareaccess.com登录。API 认证失败仍返回 JSON 401。 - 监控:
limooo-statusWorker 每分钟探测(probe)HTTP/D1 目标,探针处于故障状态时每 10 秒重新检查一次,并把结果存入 D1;告警通过 webhook 发出(Email binding 作为后备) - 状态页(
status.comicine.com):由同一个 Worker 从 D1 服务端渲染,支持 4 种语言与显式?lang=覆盖,探针错误码有四种语言的对照表,并自带品牌化的 404/503 故障页,每日在线率取自probe_uptime_daily汇总表 - 自动 IP 封禁:
- D1
blocked_ips是权威数据源;sync-worker把生效的行同步到 Cloudflare IP List,用于边缘拦截(没有源站,因此也不需要 ipset/iptables) - 应用层全局过滤器作为后备 —— 被封禁的 IP 直接收到 403
- D1
- 统一跳转页(
redirect.comicine.com/?to=<https-url>,也接受/r):在跳转到任意 HTTPS 目标之前显示一个中转提示页 - 作品集图片处理:原图永不对外发布;只有加水印的完整图片(
image.comicine.com/portfolio/*)和干净的缩略图是公开的
技术栈
| 层 | 技术 | 运行位置 |
|---|---|---|
| 公开页面、门禁、访客面板、Apple Account 管理器、状态页 | Pages Functions(functions/)+ 预渲染静态 HTML | Cloudflare 边缘 |
| 数据 | D1(访客分析、黑名单、Apple Account 账号、认证会话、探针/心跳) | Cloudflare |
| 人类验证 | Cloudflare Turnstile,门禁页原地渲染 | Cloudflare + 浏览器 |
| 认证 | Cloudflare Access(Zero Trust JWT,由 Pages Function 自行验证) | Cloudflare |
| 监控与数据保留 | ops/status-worker(Cron Triggers + Durable Object alarms) | Cloudflare Workers |
| D1 归档 | ops/d1-archive(每日 cron) | Cloudflare Workers |
| 黑名单同步 | ops/sync-worker(每日 cron → Cloudflare IP List) | Cloudflare Workers |
| 图片水印 | ops/image-watermark(路径归一化代理) | Cloudflare Workers |
| 静态资源 / 原图备份 | Pages 资源服务器 + 私有 R2 桶 limooo-originals | Cloudflare |
| 部署 | ops/deploy.sh(git + ops/pages_deploy.sh + ops/workers_deploy.sh) | 本地 |
项目结构
├── src/
│ ├── config.py # unified config: paths, languages, domains, DB/IP utils (consumes config-contract.json)
│ ├── auto_block.py # manual blocklist.txt → D1 reconciliation (D1 is the authority)
│ ├── cidr.py # CIDR parsing helpers
│ ├── portfolio.py # portfolio image / thumbnail helpers
│ ├── render_app.py # build-time read-only renderer used by src/build.py
│ ├── services_pricing.py # CSV-driven price list for services.comicine.com
│ ├── build.py # Pages static build (python3 src/build.py)
│ ├── static/ # static css/js/fonts + icons/portfolio/QR codes; files/ maps to /public/
│ └── templates/ # Jinja2 page templates
├── README.md # this file
├── LICENSE.txt # GNU AGPL v3 官方英文正文
├── ADDITIONAL_TERMS.txt # Section 7 附加条款
├── ADDITIONAL_TERMS_zh_CN.txt # Section 7 附加条款(简体中文)
├── ADDITIONAL_TERMS_ja_JP.txt # 日语译本
├── ADDITIONAL_TERMS_ko_KR.txt # 韩语译本
├── COPYRIGHT.md # 版权与许可说明(英文)
├── COPYRIGHT_zh_CN.md # 简体中文译本
├── COPYRIGHT_ja_JP.md # 日语译本
├── COPYRIGHT_ko_KR.md # 韩语译本
├── THIRD_PARTY_NOTICES.md # 第三方声明(英文)
├── THIRD_PARTY_NOTICES_zh_CN.md # 简体中文译本
├── THIRD_PARTY_NOTICES_ja_JP.md # 日语译本
├── THIRD_PARTY_NOTICES_ko_KR.md # 韩语译本
├── LICENSE_zh_CN.txt # AGPL-3.0 (Simplified Chinese)
├── LICENSE_ja_JP.txt # AGPL-3.0 (Japanese)
├── LICENSE_ko_KR.txt # AGPL-3.0 (Korean)
├── config-contract.json # single source of domains / languages / cookies / TTLs
├── package.json # npm scripts: build / test / typecheck
├── package-lock.json # locked Node dependency tree (npm ci)
├── requirements.lock # pinned Python dependencies (read by ops/build.sh)
├── wrangler.toml # Cloudflare Pages project + D1 binding
├── tsconfig.json # TypeScript config for functions/ and the Workers in ops/
├── vitest.config.ts # vitest config (functions/**/*.test.ts)
├── pytest.ini # pytest config (tests/)
├── pyproject.toml # ruff / mypy config
├── .gitignore # ignore rules shipped with the repository
├── .github/ # GitHub Actions workflows
│ └── workflows/
│ └── tests.yml # CI: build, then typecheck / migrations / vitest / pytest
├── .githooks/ # git hooks (installed by ops/install_git_hooks.sh)
│ └── pre-push # runs ops/ci_check.sh on the commit being pushed
├── data/ # runtime databases and caches (generated; git-ignored)
├── secrets/ # secrets & certificates, git-ignored
│ ├── webauthn.env # env file read by the deploy scripts
│ └── apple_account_encryption.key # Apple Account password encryption key
├── ops/ # deployment & ops tooling
│ ├── deploy.sh # the single deploy entry point (commit/push + Pages + docs + Worker)
│ ├── build.sh # Pages build: venv, contract checks, public/manifest.json
│ ├── pages_deploy.sh # Cloudflare Pages build + Wrangler deploy
│ ├── docs_deploy.sh # docs.comicine.com build + deploy (VitePress → Pages project comicine-docs)
│ ├── docs_headers.py # generates the docs site `_headers` (CSP hashes + cache)
│ ├── ci_check.sh # local replica of .github/workflows/tests.yml
│ ├── run-tests.sh # test runner used locally
│ ├── install_git_hooks.sh # installs the pre-push hook
│ ├── readme_facts.py # re-reads the facts on this page (--check / --live)
│ ├── migrate_d1.sh # D1 migrations (--dry-run / --remote)
│ ├── workers_deploy.sh # standalone Worker deploys
│ ├── export_d1.py # unified D1 import SQL/JSON export (apple-account | blocklist)
│ ├── prune_d1.py # manual D1 retention and aggregation (scheduled retention runs in status-worker)
│ ├── d1_client.py # Cloudflare API client for D1
│ ├── upload_originals.sh # private R2 backup of portfolio originals
│ ├── security-headers.json # single source of the response-header baseline
│ ├── tailwind.config.js # Tailwind config for the prebuilt CSS
│ ├── check_config_contract.py / check_gate_trust.py / check_security_headers.py / check_ip_rays.py / check_ray_id.py / check_visitor_id.py
│ ├── migrations/ # D1 schema migrations
│ ├── waf/ # WAF rule snapshots
│ ├── cloudflare/ # Cloudflare resource inventory (declarative)
│ ├── email-templates/ # transactional email framework + i18n copy
│ ├── fonts/ # gate-diagnostic font subset
│ ├── status-worker/ # Worker: probes, status page, alerting, D1 retention
│ ├── image-watermark/ # Worker: image.comicine.com watermark normalizer
│ ├── d1-archive/ # Worker: D1 snapshot/archive (cron 00:00)
│ ├── sync-worker/ # Worker: D1 blocked_ips → Cloudflare IP List (cron 03:30)
│ ├── requirements.txt # Python dependencies
├── functions/ # Cloudflare Pages Functions
│ ├── _middleware.ts # gate/redirect/blocklist/visitors/ray orchestration (test: _middleware.test.ts)
│ ├── _lib/ # config, d1, cidr, gate, access, session, fernet, routing
│ ├── _data/ # generated i18n/runtime modules (do not hand-edit)
│ ├── api/ # apple-account, auth, i18n, ray, visitors endpoints
│ ├── types.d.ts # shared Env / PagesFunction types
│ └── login.ts / logout.ts # Access login callback and logout (tests: *.test.ts)
├── tests/ # pytest suites + fixtures
├── locales/ # i18n / translation catalogs
├── public/ # Pages build output (git keeps only .gitkeep)
├── preview/ # local preview (build-generated; git keeps only .gitkeep)
└── docs/ # docs.comicine.com VitePress root + services.comicine.com price-list CSVs快速开始
# 安装 package-lock.json 锁定的 Node 依赖
npm ci
# 构建静态站点(Python 虚拟环境 + 契约校验 + public/ + preview/)
npm run buildnpm run build 就是 ops/build.sh:它会在仓库外创建构建虚拟环境 /tmp/comicine-build-venv,从 requirements.lock 安装锁定的依赖, 并在渲染前校验跨运行时配置。作品水印由 Pillow 使用 src/static/icons/watermark.png 生成,不需要系统 Cairo 库。 若要直接运行渲染器,请先安装锁定的 Python 依赖,再执行 COMICINE_BUILD=1 python3 src/build.py。
若要在本地预览生成后的站点,请先构建,然后打开 preview/ 中的产物。
构建、测试与部署
为获得干净的本地构建,请使用与部署脚本相同的依赖集合:
cd site
npm ci
npm run buildsrc/build.py 会重新生成 public/<lang>/*.html、public/static/、 functions/_data/i18n.ts、functions/_data/runtime.ts 与 preview/。 不要手动编辑这些产物;应修改 locales/*.json、模板或静态源文件后重新构建。 src/static/tailwind.css 是已入库的 Tailwind 预构建产物。 npm run build 使用 ops/build.sh,它会创建 /tmp/comicine-build-venv 并生成 public/manifest.json(构建产物哈希凭据)。
只部署 Pages 产物时使用:
# build + validate only, no Cloudflare writes
bash ops/pages_deploy.sh --build-only
# build + validate + deploy Pages + smoke test (/_health must be 200)
bash ops/pages_deploy.sh
# full deploy: (1) commit (2) push (3) deploy Pages + docs
bash ops/deploy.sh --all部署入口:ops/deploy.sh 是唯一的部署入口 —— commit + push + Pages + docs, 并可用 --worker=<name> 单独部署一个 Worker;ops/pages_deploy.sh 负责构建 + Pages。 --dry-run 只打印计划,不向任何远端写入。
默认情况下 deploy.sh 是安静模式:每个步骤一行状态, 只有该步骤失败时才输出它的完整日志。加上 --full 可流式输出全部内容 (构建清单、产物数量、wrangler 上传进度)。两种模式下标志与步骤完全相同 —— --full 只改变详细程度。
凭据从本地 secrets/webauthn.env 读取(从不提交、从不回显); 没有 ssh / rsync / 远端 systemd 步骤。ops/migrate_d1.sh 与 ops/workers_deploy.sh 也支持 --dry-run。
已提供自动化测试入口:
# Python 测试
python3 -m pytest
# Pages Functions, Workers and image-watermark tests
npm test环境变量
由部署脚本从 secrets/webauthn.env 读取,不提交到 Git。Pages 运行时有一套自己的 Secret(见下文的边缘章节)。
| 变量 | 说明 |
|---|---|
GATE_HMAC_KEY | __gate cookie 的 HMAC-SHA256 密钥,在边缘签发并校验 |
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID | 供 ops/d1_client.py、ops/pages_deploy.sh 与 ops/workers_deploy.sh 使用的 Cloudflare API 访问凭据 |
密钥材料存放在仓库之外;apple_account_encryption.key 是用于 Apple Account 密码 与加密访客 IP 字段的 Fernet 密钥。
定时任务
所有调度都位于 Cloudflare 上,以各 Worker 的 wrangler.toml 中声明的 Worker Cron Triggers 形式存在:
| 调度 | Worker | 任务 |
|---|---|---|
* * * * * | ops/status-worker | 每分钟执行 HTTP/D1 探针;探针故障期间由 Durable Object alarm 每 10 秒复查一次,并在状态变化时告警 |
47 3 * * * | ops/status-worker | D1 数据保留(src/retention.ts):清理 ray_log_v2(7 天)、visitors_v2(30 天)、visitor_rollups(30 天)、heartbeats(30 天)、events(90 天)、probe_uptime_daily(90 天)、auth_sessions(60 天)与 identity_handoffs(1 天) |
30 3 * * * | ops/sync-worker | 把生效的 D1 blocked_ips 行同步到 Cloudflare IP List limooo_blocklist |
0 0 * * * | ops/d1-archive | D1 快照 / 归档 |
python3 ops/readme_facts.py --check 会从仓库重新读一遍上面的调度、保留期、TTL 与目录树, 与本页不一致就让检查失败;--live 则从 Cloudflare 读线上状态(D1 计数、Pages 主机与 Secret、DNS、WAF、Worker 调度)。
TLS 证书由 Cloudflare 为 Pages 自定义域名签发与续期。 ops/migrate_d1.sh 与 ops/workers_deploy.sh 是手动的、 支持 --dry-run 的维护入口。
部署
部署目标就是 Cloudflare Pages 项目 comicine(默认别名 limooo.pages.dev)以及各个独立 Worker,全部走 Cloudflare API。 在仓库根目录下执行:
cd site
bash ops/deploy.sh # with no arguments = --all (full deploy, see below)
bash ops/deploy.sh --all # commit + push + deploy Pages + docs
bash ops/deploy.sh --pages # deploy the main Pages project only
bash ops/deploy.sh --docs # deploy docs.comicine.com only
bash ops/deploy.sh --worker=status-worker # deploy one standalone Worker
bash ops/deploy.sh --all --full # same, but stream every step's output不带任何参数运行 ops/deploy.sh 完全等同于 --all:commit + push + Pages + docs,即「完整部署」契约。若要只发布本地已提交的内容, 请显式传入 --pages / --docs。
输出默认是安静模式(每步一行状态);加上 --full 可观察整个过程。 --dry-run 可与上述任意标志组合使用。
凭据从本地 secrets/webauthn.env 读取;所有步骤都走 Cloudflare API 或 Git。
文档站(docs.comicine.com)
site/docs/ 是一个按子域分目录的容器:docs/ 存放 docs.comicine.com 的 VitePress 根(每种语言每页一个 markdown 文件), services/ 存放 services.comicine.com 的价目表 CSV。 语言码是 URL 的最后一段,内容页一律显式带上(/README/zh-cn、/README/en-us); 无后缀的 /README 会 302 到 /README/zh-cn;文档站根路径 / 会 302 到 https://comicine.com。
.vitepress/rewrites.json(与内容同级)把源文件映射到带后缀的路由, .vitepress/config.mts 通过 additionalConfig 为每个页面设置各自的 lang / themeConfig。新增页面时,只需在每个语言目录中各放一个 markdown 文件,再加一条 rewrites 条目, 并在 public/_redirects 里为无后缀路径补一条 302 —— 如果任何 markdown 文件没有产出 HTML, ops/docs_check_output.py 会让构建失败。
该站点使用来自 fork ComiCine/vitepress 的 VitePress 构建: 页头与页脚位于 fork 中(VPComiCineNav.vue / VPComiCineFooter.vue), 并与主站的 base.html / _footer.html 逐条对齐 —— 在那里修改它们, 下一次部署就会生效(只有当 fork 的 commit 变化时才会重新构建它; 使用 COMICINE_VITEPRESS_FETCH=0 可从 fork 工作区构建)。该站点是独立的 Cloudflare Pages 项目(comicine-docs),因此绝不会碰到主 comicine 的产物, 并且与主站共享 user_lang_preference / comicine_theme 两个 cookie。
bash ops/docs_deploy.sh --build-only # build + validate only
bash ops/docs_deploy.sh --dev # local VitePress dev server安全设计
- 会话 cookie 使用
Secure+HttpOnly+SameSite=Lax,并限定在当前主机,避免会话 cookie 被其他站点复用; 每个会话都有一个随机sid记录在 D1auth_sessions中,requireAuth会拒绝已撤销/已过期的会话。运行时 HMAC 密钥缺失或auth_sessions表 不可用时,以 503 快速失败。 - 门禁同样快速失败:当
TURNSTILE_SECRET、GATE_HMAC_KEY或SESSION_HMAC_KEY为空时,边缘返回 503, 而不是渲染页面或签发未签名的 cookie。 __gatecookie 的形式是<unix-expiry>.<HMAC-SHA256 hex>(1 小时 TTL,HttpOnly,Domain=.comicine.com),完全在边缘签发与校验。- 未登录浏览器访问受保护页面时,来源站点先返回 HTTP 401 HTML,并设置短时 state cookie;同源外置脚本再跳转到
identity.comicine.com/login,Cloudflare Access 随后引导浏览器经limooo.cloudflareaccess.com登录。API 认证失败仍返回 JSON 401。identity 主机上的 Pages Function 自行验证Cf-Access-Jwt-Assertion(RS256,JWKS 缓存 1 小时),将来源主机和 state 绑定到哈希存储、单次使用的 D1 交接码;来源站点回调再通过服务端请求消费代码,并按identity_sites中的本地配置授予角色。交接内容不包含角色;当前 visitor 与 account 两个站点的 API 都要求 admin 会话。 - 密钥与密文分开存储,且从不提交到仓库
- 封禁层级:应用层的 D1 黑名单在边缘直接 403,
ops/sync-worker把生效行同步到 Cloudflare IP Listlimooo_blocklist供 WAF 规则使用(该自定义规则目前未部署) - 管理写入(创建/更新/删除)需要 admin 角色;viewer 为只读
- 列表接口永不返回访客 IP;完整 IP 以 Fernet 加密存放在
visitor_rollups.ip_enc中, 仅对管理员逐行解密
白名单
手工维护的可信来源位于 docs/lists/own-ips.txt,部署时会生成 docs/lists/whitelist.txt:
| 条目 | 效果 |
|---|---|
ASN/<number> | 可选的 Cloudflare 区域级 ASN Access Rule 条目。需要手动运行 bash ops/waf_whitelist.sh --apply 才会更改区域规则;ASN 条目不会绕过 Pages Functions 门禁。 |
IP-CIDR/<ip>/<mask> | Pages 边缘完全放行来源(例如 IP-CIDR/97.64.18.11/32);IPv4 前缀只接受 /24–/32,IPv6 只接受 /64–/128,同时跳过封禁检查与人机验证。 |
当前生成名单有 0 个 ASN 条目和 2 个 IP 网段。完整部署只生成本地白名单,不会更改 Cloudflare 区域级 ASN Access Rules。编辑 own-ips.txt 后,部署会运行 ops/sync_whitelist.py;构建随后运行 ops/check_gate_trust.py --emit 生成边缘副本。
权威来源
- 面向用户的文案:
locales/*.json;functions/_data/*与 API 的 i18n 路由都由它生成。 - 共享运行时常量:
config-contract.json是约定的跨运行时契约;src/config.py与生成的functions/_lib/config.ts都消费它,并由ops/check_config_contract.py强制保持一致。 - 门禁/跳转文案:经由
functions/_data/runtime.ts来自locales/*.json;由src/build.py组装。 - D1 结构与迁移:
ops/migrations/*.sql;blocked_ips是封禁的唯一权威。 - 安全响应头基线(启用时):
ops/security-headers.json。 - 部署与运行时边界:
ops/deploy.sh;只有 Cloudflare Pages + Workers 两个运行时。
Cloudflare Pages 运行时
公开站点(首页 / 服务 / 联系、门禁、访客面板、Apple Account 管理器、images.comicine.com 静态资源主机与跳转中继)运行在 Cloudflare Pages Functions 上,状态页与所有定时任务也是如此。主 Pages 项目名称为 comicine,当前默认别名仍是 limooo.pages.dev;文档项目 comicine-docs 的默认别名仍是 limooo-docs.pages.dev,子域迁移尚未完成。
运行时划分:
| 层 | 技术 |
|---|---|
| 边缘 / 人类验证 | Pages Functions(functions/_middleware.ts) |
| 页面 | 构建时预渲染的静态 HTML(多语言) |
| 数据 | D1(访客分析、黑名单、Apple Account 管理、认证会话) |
| 人类验证 | Cloudflare Turnstile,门禁页原地渲染 |
| 独立 Worker | status.comicine.com(limooo-status:探针、状态页、数据保留)、image.comicine.com(image-watermark)、limooo-blocklist-sync |
构建与目录结构
python3 src/build.py:把页面以 4 种语言预渲染到public/,把locales/*.json内联为functions/api/i18n/[lang].ts,并生成共享的functions/_data/runtime.ts(门禁/跳转 i18n + 预加载资源)ops/migrations/001_init.sql:D1 初始结构(apple_accounts/blocked_ips/visitors)ops/export_d1.py:生成 D1 导入 SQL(输出在ops/out/,已被 git 忽略)ops/migrations/007_visitor_status_indexes.sql:为访客状态筛选新增(status, ts)与(status, ip_hash, ts)索引ops/sync-worker/:每日 03:30 的 Worker cron 把生效的 D1blocked_ips行同步到 Cloudflare IP List;auto_block.py cf仅用于显式的人工维护
环境变量
配置在 Pages 项目设置 → 环境变量 → 加密(Secret) 下,不提交到仓库:
| 变量 | 用途 |
|---|---|
TURNSTILE_SITEKEY | 门禁页上 Turnstile 组件的公开 sitekey |
TURNSTILE_SECRET | 服务端 siteverify 密钥 |
GATE_HMAC_KEY | __gate cookie 的 HMAC-SHA256 签名密钥(openssl rand -hex 32) |
ACCESS_TEAM_DOMAIN | Cloudflare Access 团队域名;同时也是用于验证的 JWT iss |
ACCESS_IDENTITY_AUD | 单一 ComiCine Access 应用的 AUD;角色由 config-contract.json 的来源主机映射决定 |
VISITOR_IP_KEY | 用于加密完整访客 IP 字段(visitor_rollups.ip_enc)的 Fernet 密钥 |
OBSERVABILITY_HMAC_KEY | 隐私最小化访客 IP 哈希的 HMAC 密钥(functions/_lib/tracking.ts);缺失时门禁 fail-closed |
SESSION_HMAC_KEY | Pages 会话 cookie 签名密钥(与 GATE_HMAC_KEY 分开) |
APPLE_ACCOUNT_ENCRYPTION_KEY | Fernet 密钥(来自 secrets/apple_account_encryption.key) |
本地开发:把 .dev.vars.example 复制为 .dev.vars 并填入真实值(已被 git 忽略)。通过 wrangler secret put 为 ops/sync-worker 配置 CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID。
门禁行为
每个请求都会检查已签名的 __gate cookie。Cloudflare 的 botManagement.verifiedBot 被视为经过验证的搜索引擎信任信号;任意伪造的 Googlebot/GPTBot User-Agent 字符串以及客户端自带的 cf_clearance cookie 都不能绕过门禁。边缘代码只信任生成的白名单(docs/lists/whitelist.txt → functions/_data/gateTrust.ts)来做完全绕过。
未验证的请求会原地获得 Turnstile 门禁页:主机与路径始终不变。中间件在请求的 URL 上以状态码 403 渲染 public/<lang>/auth.html,POST /__gate/verify 在同一来源上返回 Set-Cookie: __gate=…(1 小时,Domain=.comicine.com),随后页面重新加载最初的目标地址。auth.comicine.com 是门禁主机 —— 它在自己的根路径上提供同一个页面,且拥有 /__gate/config|diag|verify 端点。
每个渲染门禁的主机都由同一份边缘代码提供服务,因此不存在需要保持同步的第二套门禁实现。
中间件还会强制执行归一化后的 D1 黑名单,并记录隐私最小化的访客分析数据。门禁页为 no-store/noindex,并支持深色/浅色主题切换。Turnstile widget 的主机名授权必须覆盖实际使用它的站点。当前 .com 门禁仍存在验证故障:配置接口和 Turnstile 脚本都能加载,但挑战在 /gate/verify 收到 token 前失败;auth.js 现在会显示手动重试按钮并在浏览器控制台记录 Turnstile 数字错误码。现有 Cloudflare API 凭据修改 widget 时返回 HTTP 403 / error 10000,因此授权主机名尚未核验或修复。不要关闭门禁;排查时先看客户端错误码,并用有 Turnstile Sites Write 权限的凭据核对主机授权。
页面服务(干净 URL,无语言路径前缀)
src/build.py 以 4 种语言预渲染 public/<lang>/;门禁通过之后,中间件按 cookie > Accept-Language > CF 区域 > en-US 的顺序选择语言,并通过 env.ASSETS.fetch() 获取匹配的页面,从而保持 URL 干净:
comicine.com/→ 首页;comicine.com/services/comicine.com/contact→ 对应页面services.comicine.com/→ 服务页;contact.comicine.com/→ 联系页(子域直接提供内容,不 301 到主站)visitor.comicine.com/→ 访客面板(需要登录);account.comicine.com/apple→ Apple Account 管理器(需要登录)images.comicine.com/→ 301 到comicine.com/;其他路径提供作品集图片、favicon、logo 与二维码等静态资源。image.comicine.com/portfolio/<img>→ 来自归一化 Worker 的加水印版本(其根路径也 301 到comicine.com/)www.comicine.com→ 301 到主站- 根路径下的
x.html别名在x是现有公开首页子域名(services、contact、docs、status)时返回 302 到x.comicine.com/;名称无效时返回 302 到当前站点首页。index.html返回当前主机的规范首页 - 导航链接保持绝对子域 URL(
https://services.comicine.com等);语言切换是纯前端的applyLang(),不重新加载、不改变 URL
services.comicine.com 价目表(CSV 驱动)
服务价目表是数据,而不是标记:src/build.py 在每次渲染时读取 docs/services/*.csv(src/services_pricing.py),并填充 src/templates/services.html 中的套餐网格。改价格就意味着改 CSV 然后部署 —— 不需要改模板。
| 文件 | 列 | 填充内容 |
|---|---|---|
docs/services/convention.csv | shots,price[,bookable] | 01 场照拍摄(Convention),每个张数档位一张卡片 |
docs/services/outdoor.csv | type,people,price[,bookable] | 02 正片拍摄(Outdoor),棚拍/外景 × 单人/双人 |
只有数字、档位集合与 bookable 列来自 CSV; 标签、单位后缀与说明栏仍然来自 locales/*.json (plan_studio_solo、unit_per_shot 等)。
bookable(可选列) 同时驱动删除线价格样式 与说明栏中的棚拍行:
- 按档位:
no→ 该档价格渲染时带class="plan-price strikethrough";yes、空单元格或整列缺失 → 正常价格 - 说明栏:如果所有棚拍档位都是
no,棚拍行显示暂停文案 (studio_paused);只要有一个棚拍档位可预约, 就改显示studio_bookable
因此「暂时不接单」是端到端的数据:把 outdoor.csv 中棚拍的 no 改成 yes, 删除线与暂停说明会在下一次部署时同时消失,无需改动模板或语言文件。 两者永远不会互相矛盾。任何其它取值(例如 maybe)都会让构建失败, 而不是默默猜测。
price 不是正整数时一律渲染成 -。 没有占位符白名单:留空、N/A、TBD、 写错的数字(1OO)、0、负数都表示「价格暂不公开」。卡片保留 CNY 前缀与 单位后缀,只把数字换成 -(CNY - / 张),因此版式与数字档位完全一致。 - 既不是 0 也不是「免费」;在同一个文件里混用这类单元格与数字行没有问题。
契约的其余部分:
- 场照拍摄的行按张数升序来自 CSV;档位数量不固定 (新增一个 12 张的档位不需要改代码)
- 只有当某个场照拍摄档位登记在
CONVENTION_UNIT_KEYS中时才会带单位后缀; 未登记的档位只渲染裸价格 - 正片拍摄的行必须覆盖全部四种
type/people组合;无论行序如何, 它们都按固定顺序渲染(棚拍单人/双人,然后外景单人/双人)
文件缺失、列不正确、档位重复、档位无法识别或 bookable 取值非法, 都会让构建失败 —— 价目表出错比构建失败更严重。价格单元格本身永远不会让 构建失败:解析不出正整数的内容一律渲染成 -。 tests/test_services_pricing.py 覆盖了上述所有情况,并包含一项针对已入库 CSV 的 往返校验。
docs/services/ 已被排除在 VitePress 构建之外 (docs/.vitepress/config.mts 中的 srcExclude),因此这些 CSV 始终只是数据源, 绝不会发布到 docs.comicine.com。
性能 / 边缘缓存
public/_routes.json把/static/*与根级静态资源排除在 Pages Functions 之外, 因此 CSS/JS/字体由 Pages 资源服务器直接提供。src/static/files/中的文件会镜像到构建产物/public/files/并保留子目录,因此src/static/files/<path>可通过https://comicine.com/public/files/<path>访问。/public/files/*绕过 Pages Functions,并使用与其他静态资源相同的一天缓存策略;不存在的路径返回 404。public/_headers为带版本号的静态资源设置带stale-while-revalidate的长浏览器缓存;public/_routes.json与_headers都由src/build.py生成。- 预渲染的公开 HTML 由进程内页面缓存复用(键为 host + 语言),响应声明
public, max-age=300, stale-while-revalidate=3600与Vary: Accept-Language。 这里不再声称边缘缓存:Cloudflare 不缓存 Pages Functions 的响应 (同一 URL 连续请求cf-cache-status恒为DYNAMIC),所以共享缓存的生命周期指令是死配置; 语言 cookie 也不放进Vary,否则只会把浏览器缓存切得更碎。 - 第一方作品集缩略图与 favicon 使用
images.comicine.com/static/...(静态边缘缓存,绕过 Functions),而不是水印 Worker; 二维码与外部热链图片 仍然使用image.comicine.com。 - 作品集原图永不发布:
/static/portfolio/<img>返回 404。 只有干净的缩略图(images.comicine.com/static/portfolio/thumbs/<img>-<width>.{webp,avif}) 与image.comicine.com上归一化、加水印的/portfolio/<img>是公开的; 原图保留在被 git 忽略的src/static/portfolio/与私有 R2 桶limooo-originals中(ops/upload_originals.sh)。 - Turnstile 验证有 3 秒的服务端超时, 这样 Cloudflare 质询平台故障时会快速失败, 而不是让用户最多卡住 8 秒。
生产状态
线上部署的形态,随时可用 python3 ops/readme_facts.py --live 重新读取:
- Pages:主项目
comicine当前仍通过limooo.pages.dev提供服务;文档项目comicine-docs的默认别名仍是limooo-docs.pages.dev。主项目承载comicine.com、www、services、contact、auth、visitor、account、identity、images、image与redirect;文档主机为docs.comicine.com,fonts.comicine.com由 R2 提供 - D1:数据库
limooo(APAC)以DB绑定到项目,schema_version记录已应用迁移。迁移 022 已应用;迁移 021 会重写现有 Apple Account 标识符,因此仍按设计暂缓。apple_accounts保存 Apple Account 数据,blocked_ips是封禁权威,软删除行保留用于审计 - Secret 位于 Pages → Settings → Environment variables → Encrypt:Turnstile 两个密钥、
GATE_HMAC_KEY、SESSION_HMAC_KEY、OBSERVABILITY_HMAC_KEY、VISITOR_IP_KEY、APPLE_ACCOUNT_ENCRYPTION_KEY、ACCESS_TEAM_DOMAIN与ACCESS_IDENTITY_AUD;旧的ACCESS_ADMIN_AUDS和ACCESS_VIEWER_AUDS仍有配置但已无运行时代码读取,APPLEID_ENCRYPTION_KEY与两个AUTHENTIK_*也是历史遗留 - Access:
ComiCine保护identity.comicine.com/login;visitor.comicine.com与account.comicine.com通过共享身份交接,再由各自的 Pages 主机 Cookie 保持会话。Dashboard 中仍保留未使用的ComiCine-Admin应用,指向没有 DNS 记录或 Pages 自定义域名的admin.limooo.cn。没有自建 IdP,也没有自定义登录页 - Worker 独立运行:
limooo-status、limooo-blocklist-sync、limooo-d1-archive与image-watermark;status.comicine.com与sink.comicine.com是 Worker 自定义域名 - Cloudflare 规则:自定义防火墙阶段为空(
limooo_blocklistIP List 已就绪,等待规则);缓存阶段将静态资源、旧图片路径与水印 Worker 响应缓存一年,两条 HTML 页面缓存规则均未启用;.com区域的动态重定向阶段为空;旧.cn区域将 GET/HEAD 请求以 301、其他方法以 308 跳转到对应.com主机,并保留路径和查询参数。/feishu/<path>先按同一规则 301 到comicine.com/feishu/<path>,再由 Pages 转发到https://comicine.feishu.cn/<path>。 - 冒烟检查:
https://comicine.com/_health→ 200,https://comicine.com/?challenge=1→ 403,https://docs.comicine.com/→ 302 到https://comicine.com,https://status.comicine.com/→ 200
注意:?challenge=1 返回 403 只证明门禁页已渲染,不证明 Turnstile 可验证;应在浏览器完成挑战并确认 /gate/verify 成功。当前 .com 挑战还未恢复。
门禁在每个主机上都原地渲染,门禁页与日志显示真实访客 IP,Access 仍是唯一的身份来源。
许可证
ComiCine 拥有并有权授权的原创材料依据 GNU Affero 通用公共许可证第 3 版或任何后续版本(AGPL-3.0-or-later)授权。GNU AGPL v3 第 7 条附加条款见 ADDITIONAL_TERMS_zh_CN.txt;该等附加条款适用于采用 GNU AGPL v3 的情形,后续版本是否适用取决于相应版本的条款。GNU AGPL v3 官方英文全文见 LICENSE.txt,简体中文参考译本见 LICENSE_zh_CN.txt。
ComiCine 是项目使用的开发品牌及公开署名;版权归属于依法享有相关权利的实际权利人。再分发时必须保留适用的作者署名和版权声明,包括合理的来源声明。遵守适用 GNU AGPL 版本即可进行商业使用。
额外授权或商标许可咨询:[email protected](该旧邮箱继续使用)。遵守适用 GNU AGPL 版本的商业使用无需额外申请授权。