ComiCine โ
A fully serverless personal website and admin system running at comicine.com. Public pages, the human-verification gate, the visitor panel, the Apple Account manager, monitoring and the status page all run on Cloudflare (Pages Functions, Workers, D1, R2).
Features โ
- Public pages: Home, Services, Contact, Portfolio โ pre-rendered in 4 languages with dark/light theme switching
- Human verification: the Turnstile gate page is rendered in place at the requested URL (no cross-domain hop) on every host โ see Gate behavior
- Visitor panel (
visitor.comicine.com): Pages Function + D1 analytics; shows a hashed visitor identifier (no raw IP in list APIs), country, ISP/ASN where available, status-code distribution, and is login-protected - After the first load, visitor status chips filter locally with no new
/api/visitorsrequest; the API still accepts?status=<3-digit>for deep links. - Apple Account manager (
account.comicine.com/apple): Pages Function + D1 CRUD with drag-and-drop ordering; passwords are stored encrypted with Fernet, the list shows only masked passwords, with temporary plaintext reveal - Auth & roles: one
ComiCineAccess application protectsidentity.comicine.com/login; the identity Pages Function verifies its JWT and returns a short-lived, one-time D1 handoff bound to the registered source host, then that host applies its local role. Both current protected hosts require admin sessions. - Shared login navigation: an unauthenticated browser page request receives HTTP 401 HTML, then a same-origin external script navigates to
identity.comicine.com/login; Cloudflare Access sends the browser throughlimooo.cloudflareaccess.com. API authentication failures remain JSON 401 responses. - Monitoring: the
limooo-statusWorker probes HTTP/D1 targets every minute, re-checks every 10s while a probe is down, and stores results in D1; alerts go out over a webhook (Email binding as fallback) - Status page (
status.comicine.com): server-rendered from D1 by the same Worker in 4 languages with an explicit?lang=override, a four-language probe error-code table, branded 404/503 failure pages, and per-day uptime taken from theprobe_uptime_dailyrollup - Automatic IP blocking:
- D1
blocked_ipsis the authority;sync-workermirrors active rows to a Cloudflare IP List for edge interception (no origin, so no ipset/iptables) - Application-layer global filter as a fallback โ banned IPs get a direct 403
- D1
- Unified redirect page (
redirect.comicine.com/?to=<https-url>,/ralso accepted): shows an interstitial before redirecting to any HTTPS destination - Portfolio image handling: originals are never published; only watermarked full images (
image.comicine.com/portfolio/*) and clean thumbnails are public
Tech stack โ
| Layer | Technology | Runs on |
|---|---|---|
| Public pages, gate, visitor panel, Apple Account manager, status page | Pages Functions (functions/) + pre-rendered static HTML | Cloudflare edge |
| Data | D1 (visitor analytics, blocklist, Apple Account accounts, auth sessions, probes/heartbeats) | Cloudflare |
| Human verification | Cloudflare Turnstile, gate page rendered in place | Cloudflare + browser |
| Auth | Cloudflare Access (Zero Trust JWT, verified by a Pages Function) | Cloudflare |
| Monitoring & retention | ops/status-worker (Cron Triggers + Durable Object alarms) | Cloudflare Workers |
| D1 archive | ops/d1-archive (daily cron) | Cloudflare Workers |
| Blocklist sync | ops/sync-worker (daily cron โ Cloudflare IP List) | Cloudflare Workers |
| Image watermarking | ops/image-watermark (path-normalizing proxy) | Cloudflare Workers |
| Static assets / originals backup | Pages asset server + private R2 bucket limooo-originals | Cloudflare |
| Deployment | ops/deploy.sh (git + ops/pages_deploy.sh + ops/workers_deploy.sh) | local |
Project structure โ
โโโ 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 # Official GNU AGPL v3 text
โโโ ADDITIONAL_TERMS.txt # Section 7 additional terms
โโโ ADDITIONAL_TERMS_zh_CN.txt # Simplified Chinese translation
โโโ ADDITIONAL_TERMS_ja_JP.txt # Japanese translation
โโโ ADDITIONAL_TERMS_ko_KR.txt # Korean translation
โโโ COPYRIGHT.md # Copyright and licensing information (English)
โโโ COPYRIGHT_zh_CN.md # Simplified Chinese translation
โโโ COPYRIGHT_ja_JP.md # Japanese translation
โโโ COPYRIGHT_ko_KR.md # Korean translation
โโโ THIRD_PARTY_NOTICES.md # Third-party notices (English)
โโโ THIRD_PARTY_NOTICES_zh_CN.md # Simplified Chinese translation
โโโ THIRD_PARTY_NOTICES_ja_JP.md # Japanese translation
โโโ THIRD_PARTY_NOTICES_ko_KR.md # Korean translation
โโโ 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 CSVsQuick start โ
# Install the Node dependencies pinned by package-lock.json
npm ci
# Build the static site (Python venv + contract checks + public/ + preview/)
npm run buildnpm run build is ops/build.sh: it creates the external build virtualenv /tmp/comicine-build-venv, installs the pinned dependencies from requirements.lock, and validates the cross-runtime configuration before rendering. Portfolio watermarks use Pillow and src/static/icons/watermark.png; no system Cairo library is required. To run the renderer directly, install the locked Python dependencies and use COMICINE_BUILD=1 python3 src/build.py.
For a local preview of the generated site, build and open the output from preview/.
Build, testing and deploy โ
For a clean local build, use the same dependency set as the deployment script:
cd site
npm ci
npm run buildsrc/build.py regenerates public/<lang>/*.html, public/static/, functions/_data/i18n.ts, functions/_data/runtime.ts and preview/. Do not hand-edit those outputs; change locales/*.json, templates or static sources and rebuild. src/static/tailwind.css is the checked-in prebuilt Tailwind output. npm run build uses ops/build.sh, which creates /tmp/comicine-build-venv and generates public/manifest.json (build-artifact hash evidence).
Deploy only the Pages output with:
# 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 --allDeploy entry points: ops/deploy.sh is the single deploy entry point โ commit + push + Pages + docs, plus --worker=<name> for one standalone Worker; ops/pages_deploy.sh handles build + Pages. --dry-run prints the plan without writing to any remote.
By default deploy.sh is quiet: one status line per step, and a step's full log is dumped only if that step fails. Add --full to stream everything (build manifest, artifact count, wrangler upload progress). The flags and the steps are identical either way โ --full changes verbosity only.
Credentials are read from the local secrets/webauthn.env (never committed, never echoed); there are no ssh / rsync / remote systemd steps. ops/migrate_d1.sh and ops/workers_deploy.sh also support --dry-run.
Automated test entry points are provided:
# Python tests
python3 -m pytest
# Pages Functions, Workers and image-watermark tests
npm testEnvironment variables โ
Read from secrets/webauthn.env by the deploy scripts, not committed to Git. The Pages runtime has its own secret set (see the edge section below).
| Variable | Description |
|---|---|
GATE_HMAC_KEY | HMAC-SHA256 key for the __gate cookie, minted and validated at the edge |
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID | Cloudflare API access for ops/d1_client.py, ops/pages_deploy.sh and ops/workers_deploy.sh |
Key material lives outside the repo; apple_account_encryption.key is the Fernet key used for Apple Account passwords and the encrypted visitor IP column.
Cron jobs โ
All scheduling lives on Cloudflare as Worker Cron Triggers declared in each Worker's wrangler.toml. python3 ops/readme_facts.py re-reads this table, the retention windows and every path in the tree below:
| Schedule | Worker | Job |
|---|---|---|
* * * * * | ops/status-worker | HTTP/D1 probes every minute; a Durable Object alarm re-checks every 10s while a probe is down, and alerts on state change |
47 3 * * * | ops/status-worker | D1 retention (src/retention.ts): prunes ray_log_v2 (7d), visitors_v2 (30d), visitor_rollups (30d), heartbeats (30d), events (90d), probe_uptime_daily (90d), auth_sessions (60d) and identity_handoffs (1d) |
30 3 * * * | ops/sync-worker | Mirrors active D1 blocked_ips rows to the Cloudflare IP List limooo_blocklist |
0 0 * * * | ops/d1-archive | D1 snapshot/archive run |
TLS certificates are issued and renewed by Cloudflare for the Pages custom domains. ops/migrate_d1.sh and ops/workers_deploy.sh are manual, --dry-run-capable maintenance entry points.
python3 ops/readme_facts.py --check re-reads the schedules, retention windows, TTLs and directory tree from this repository and fails when this page drifts; --live reads the deployed side (D1 counters, Pages hosts and secrets, DNS, WAF, Worker schedules) from Cloudflare.
Deployment โ
The deploy target is Cloudflare Pages project comicine (default alias limooo.pages.dev) plus the standalone Workers, all through the Cloudflare API. From the repository root:
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 outputRunning ops/deploy.sh with no arguments is exactly --all: commit + push + Pages + docs, the "full deploy" contract. To ship only what is already committed locally, pass --pages / --docs explicitly.
Output is quiet by default (one status line per step); add --full to watch the whole process. --dry-run works with any combination of the flags above.
Credentials are read from the local secrets/webauthn.env; every step runs over the Cloudflare API or Git.
Docs site (docs.comicine.com) โ
site/docs/ is a per-subdomain container: docs/ holds the docs.comicine.com VitePress root (one markdown file per page per language), services/ holds the services.comicine.com price-list CSVs. The language code is the last URL segment and content pages carry it explicitly (/README/zh-cn, /README/en-us); a suffix-less /README 302s to /README/zh-cn. The bare docs root / 302s to https://comicine.com.
.vitepress/rewrites.json (beside the content) maps the markdown sources onto those suffixed routes, and .vitepress/config.mts gives each page its own lang / themeConfig through additionalConfig. Add a page by dropping a markdown file in each language directory, plus a rewrites entry and a 302 in public/_redirects for the suffix-less path โ ops/docs_check_output.py fails the build if any markdown file has no HTML.
The site is built with VitePress from the fork ComiCine/vitepress: the header and footer live in the fork (VPComiCineNav.vue / VPComiCineFooter.vue) and mirror the main site's base.html / _footer.html โ edit them there and the next deploy picks it up (the fork is rebuilt only when its commit changes; use COMICINE_VITEPRESS_FETCH=0 to build from a fork working tree). The site is a separate Cloudflare Pages project (comicine-docs) so it never touches the main comicine artifact, and it shares the user_lang_preference / comicine_theme cookies with the main site.
bash ops/docs_deploy.sh --build-only # build + validate only
bash ops/docs_deploy.sh --dev # local VitePress dev serverSecurity design โ
- Session cookies use
Secure+HttpOnly+SameSite=Laxand are host-only, so a session cookie cannot be reused by another site; every session has a randomsidrecorded in D1auth_sessions, andrequireAuthrejects revoked/expired sessions. Missing runtime HMAC keys or an unavailableauth_sessionstable fail closed with 503. - The gate fails closed too: with
TURNSTILE_SECRET,GATE_HMAC_KEYorSESSION_HMAC_KEYempty, the edge answers 503 instead of rendering pages or issuing unsigned cookies. - The
__gatecookie is<unix-expiry>.<HMAC-SHA256 hex>(1h TTL,HttpOnly,Domain=.comicine.com), minted and validated entirely at the edge. - An unauthenticated browser navigation to a protected page receives HTTP 401 HTML and sets the short-lived state cookie; a same-origin external script then navigates to
identity.comicine.com/login. Cloudflare Access sends the browser throughlimooo.cloudflareaccess.com, while API authentication failures remain JSON 401 responses. The identity Pages Function verifiesCf-Access-Jwt-Assertion(RS256, JWKS cached 1h) and stores a hashed, single-use D1 handoff bound to the source host and state. The source callback exchanges it server-to-server and applies its role fromidentity_sites; the handoff contains no role. Both current protected hosts require admin sessions because their APIs require them. - Keys and ciphertext stored separately, and never committed to the repository
- Blocking layers: the app-level D1 blocklist answers 403 at the edge, and
ops/sync-workerkeeps the Cloudflare IP Listlimooo_blocklistin step with the active rows for a WAF rule to consume (that custom rule is not deployed right now) - Admin writes (create/update/delete) require the admin role; viewer is read-only
- Visitor IPs are never returned by list APIs; the full IP is Fernet-encrypted in
visitor_rollups.ip_encand decrypted one row at a time for admins
Whitelist โ
Trusted sources are maintained in docs/lists/own-ips.txt and copied to the generated docs/lists/whitelist.txt during deployment:
| Entry | Effect |
|---|---|
ASN/<number> | Optional entry for a zone-level Cloudflare ASN Access Rule. Run bash ops/waf_whitelist.sh --apply manually to change zone rules; ASN entries do not bypass the Pages Functions gate. |
IP-CIDR/<ip>/<mask> | Fully allowed source at the Pages edge (for example, IP-CIDR/97.64.18.11/32); IPv4 prefixes must be /24โ/32 and IPv6 /64โ/128; skips both the blocklist and the challenge gate. |
The current generated list has 0 ASN entries and 2 IP CIDRs. A full deploy generates the local whitelist but does not change zone-level ASN Access Rules. After editing own-ips.txt, deployment runs ops/sync_whitelist.py; the build then runs ops/check_gate_trust.py --emit to generate the edge copy.
Source of truth โ
- User-facing strings:
locales/*.json;functions/_data/*and API i18n routes are generated from it. - Shared runtime constants:
config-contract.jsonis the agreed cross-runtime contract;src/config.pyand the generatedfunctions/_lib/config.tsboth consume it, withops/check_config_contract.pyenforcing agreement. - Gate/redirect copy:
locales/*.jsonviafunctions/_data/runtime.ts;src/build.pyassembles it. - D1 schema and migrations:
ops/migrations/*.sql;blocked_ipsis the sole authority for blocking. - Security response headers baseline (when enabled):
ops/security-headers.json. - Deployment and runtime boundaries:
ops/deploy.sh; Cloudflare Pages and Workers are the only runtimes.
Cloudflare Pages runtime โ
The public site (home / services / contact, the gate, the visitor panel, the Apple Account manager, the images.comicine.com static asset host and the redirect relay) runs on Cloudflare Pages Functions, as do the status page and every scheduled job. The main Pages project is named comicine but still uses limooo.pages.dev; the docs project is comicine-docs and still uses limooo-docs.pages.dev while alias migration is pending.
Runtime split:
| Layer | Technology |
|---|---|
| Edge / human verification | Pages Functions (functions/_middleware.ts) |
| Pages | Pre-rendered static HTML at build time (multi-language) |
| Data | D1 (visitor analytics, blocklist, Apple Account management, auth sessions) |
| Human verification | Cloudflare Turnstile, gate page rendered in place |
| Independent Workers | status.comicine.com (limooo-status: probes, status page, retention), image.comicine.com (image-watermark), limooo-blocklist-sync |
Build & directory layout โ
python3 src/build.py: pre-renders the pages in 4 languages intopublic/, inlineslocales/*.jsonasfunctions/api/i18n/[lang].ts, and generates sharedfunctions/_data/runtime.ts(gate/redirect i18n + preload assets)ops/migrations/001_init.sql: D1 initial schema (apple_accounts/blocked_ips/visitors)ops/export_d1.py: generate D1 import SQL (output inops/out/, git-ignored)ops/migrations/007_visitor_status_indexes.sql: adds(status, ts)and(status, ip_hash, ts)indexes for visitor status filteringops/sync-worker/: a daily 03:30 Worker cron syncs active D1blocked_ipsrows to the Cloudflare IP List;auto_block.py cfis for explicit maintenance only
Environment variables โ
Configured under Pages project settings โ Environment variables โ Encrypt (Secret), not committed to the repo:
| Variable | Purpose |
|---|---|
TURNSTILE_SITEKEY | Public sitekey of the Turnstile widget on the gate page |
TURNSTILE_SECRET | Server-side siteverify secret |
GATE_HMAC_KEY | HMAC-SHA256 signing key for the __gate cookie (openssl rand -hex 32) |
ACCESS_TEAM_DOMAIN | Cloudflare Access team domain; also the JWT iss used for verification |
ACCESS_IDENTITY_AUD | AUD for the single ComiCine Access application; source-host roles are applied by each site callback from config-contract.json |
VISITOR_IP_KEY | Fernet key for the encrypted full visitor IP column (visitor_rollups.ip_enc) |
OBSERVABILITY_HMAC_KEY | HMAC key for the privacy-minimized visitor IP hash (functions/_lib/tracking.ts); the gate fails closed without it |
SESSION_HMAC_KEY | Pages session-cookie signing key (separate from GATE_HMAC_KEY) |
APPLE_ACCOUNT_ENCRYPTION_KEY | Fernet key (from secrets/apple_account_encryption.key) |
Local development: copy .dev.vars.example to .dev.vars and fill in real values (git-ignored). Configure CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID for ops/sync-worker via wrangler secret put.
Gate behavior โ
Every request is checked for the signed __gate cookie. Cloudflare botManagement.verifiedBot is accepted as a verified search-engine trust signal; arbitrary Googlebot/GPTBot User-Agent strings and client-supplied cf_clearance cookies do not bypass the gate. Edge code trusts only the generated whitelist (docs/lists/whitelist.txt โ functions/_data/gateTrust.ts) for a full bypass.
Unverified requests get the Turnstile gate page in place: the host and path never change. The middleware renders public/<lang>/auth.html at the requested URL with status 403, and POST /__gate/verify answers on that same origin with Set-Cookie: __gate=โฆ (1h, Domain=.comicine.com), after which the page reloads the original target. auth.comicine.com is the gate host: it serves the same page at its own root and owns the /__gate/config|diag|verify endpoints.
Every host that renders the gate is served by the same edge code, so there is no second gate implementation to keep in sync.
The middleware also enforces the normalized D1 blocklist and records privacy-minimized visitor analytics. The gate page is no-store/noindex and supports dark/light theme switching. The Turnstile widget hostname allowlist must cover the sites where it is rendered. The .com gate currently has a verification failure: its config endpoint and Turnstile script load, but the challenge fails before /gate/verify receives a token. auth.js now exposes a manual retry button and logs Turnstileโs numeric client error code in the browser console. The configured Cloudflare API credentials receive HTTP 403 / error 10000 when updating the widget, so its authorized hostnames have not yet been inspected or fixed. Do not disable the gate; inspect the client error code and verify hostname authorization with credentials that have Turnstile Sites Write access.
Page serving (clean URLs, no language path prefix) โ
src/build.py pre-renders public/<lang>/ in 4 languages; after the gate passes, the middleware picks a language by cookie > Accept-Language > CF region > en-US and fetches the matching page via env.ASSETS.fetch(), keeping the URL clean:
comicine.com/โ home page;comicine.com/services/comicine.com/contactโ corresponding pagesservices.comicine.com/โ services page;contact.comicine.com/โ contact page (subdomains serve content directly, no 301 to the main site)visitor.comicine.com/โ visitor panel (login required);account.comicine.com/appleโ Apple Account manager (login required)images.comicine.com/โ 301 tocomicine.com/; other paths serve static assets such as portfolio images, favicons, logos and QR codes.image.comicine.com/portfolio/<img>โ watermarked variant from the normalization Worker (its root also 301s tocomicine.com/)www.comicine.comโ 301 to the main site- Root-level
x.htmlaliases return 302 tox.comicine.com/whenxis an active public homepage subdomain (services,contact,docsorstatus); unknown names return 302 to the current site's homepage.index.htmlreturns to the current host's canonical homepage. - Nav links keep absolute subdomain URLs (
https://services.comicine.cometc.); language switching is a pure frontendapplyLang(), no reload, no URL change
services.comicine.com pricing (CSV-driven) โ
The services price list is data, not markup: src/build.py reads docs/services/*.csv on every render (src/services_pricing.py) and fills the plan grids in src/templates/services.html. Changing a price means editing the CSV and deploying โ no template edit.
| file | columns | fills |
|---|---|---|
docs/services/convention.csv | shots,price[,bookable] | 01 Convention, one card per shot-count tier |
docs/services/outdoor.csv | type,people,price[,bookable] | 02 Outdoor, studio/outdoor ร solo/duo |
Only the numbers, the tier set and the availability come from the CSV; labels, unit suffixes and the notes block still come from locales/*.json (plan_studio_solo, unit_per_shot, โฆ).
bookable (optional column) drives both the struck-through price style and the studio row in the notes block:
- per tier:
noโ that tier's price renders withclass="plan-price strikethrough";yes, an empty cell, or the whole column absent โ normal price - notes block: if every studio tier is
no, the studio row shows the paused wording (studio_paused); as soon as one studio tier is bookable it showsstudio_bookableinstead
So "temporarily not booking" is data end to end: flip studio from no to yes in outdoor.csv and the strikethrough and the paused note both disappear on the next deploy, with no template or locale edit. The two can never disagree. Any other value (e.g. maybe) fails the build rather than silently guessing.
A price cell that is not a positive integer renders as -. There is no placeholder whitelist: empty, N/A, TBD, a typo (1OO), 0 and negative numbers all mean "price not published". The card keeps the CNY prefix and the unit suffix and only the number becomes - (CNY - / shot), so the layout stays identical to the numeric tiers. - is not 0 and not "free"; mixing such cells with numeric rows in one file is fine.
The rest of the contract is:
- convention rows come from the CSV in ascending shot-count order; the number of tiers is not fixed (adding a 12-shot row needs no code change)
- a convention tier gets a unit suffix only if it is listed in
CONVENTION_UNIT_KEYS; unlisted tiers render the bare price - outdoor rows must cover all four
type/peoplecombinations; they render in a fixed order (studio solo/duo, then outdoor solo/duo) regardless of row order
A missing file, wrong column, duplicate tier, unknown tier or unrecognized bookable value fails the build โ a wrong price list is worse than a failed build. The price cell alone never fails the build: anything that is not a positive integer renders as -. tests/test_services_pricing.py covers all of these cases plus a round-trip check against the committed CSVs.
docs/services/ is excluded from the VitePress build (srcExclude in docs/.vitepress/config.mts), so the CSVs stay a data source and are never published to docs.comicine.com.
Performance / edge caching โ
public/_routes.jsonexcludes/static/*and root static assets from Pages Functions, so CSS/JS/fonts are served directly by the Pages asset server.- Files in
src/static/files/are mirrored to/public/files/, preserving nested paths, sosrc/static/files/<path>is served athttps://comicine.com/public/files/<path>./public/files/*bypasses Pages Functions and uses the same one-day cache as other static assets; missing paths return 404. public/_headersgives versioned static assets a long browser cache withstale-while-revalidate;public/_routes.jsonand_headersare both generated bysrc/build.py.- Pre-rendered public HTML is reused through the in-process page cache (keyed by host + language), and responses advertise
public, max-age=300, stale-while-revalidate=3600withVary: Accept-Language. Edge caching is deliberately not claimed here: Cloudflare does not cache Pages Functions responses (cf-cache-statusstaysDYNAMICon repeated requests), so a shared-cache lifetime directive would be dead configuration, and the language cookie stays out ofVarybecause it would only fragment the browser cache. - First-party portfolio thumbnails and favicons use
images.comicine.com/static/...(static edge cache, bypasses Functions) instead of the watermark Worker; QR codes and externally hotlinked images still useimage.comicine.com. - Portfolio originals are never published:
/static/portfolio/<img>returns 404. Only clean thumbnails (images.comicine.com/static/portfolio/thumbs/<img>-<width>.{webp,avif}) and the normalized, watermarked/portfolio/<img>onimage.comicine.comare public; originals stay in the git-ignoredsrc/static/portfolio/and the private R2 bucketlimooo-originals(ops/upload_originals.sh). - Turnstile verification has a 3-second server-side timeout so Cloudflare challenge-platform incidents fail closed quickly instead of stalling users for up to 8 seconds.
Production status โ
The shape of the live deployment, re-read any time with python3 ops/readme_facts.py --live:
- Pages: the main project
comicinecurrently serves throughlimooo.pages.dev; the docs project iscomicine-docsand still haslimooo-docs.pages.dev. The main project servescomicine.com,www,services,contact,auth,visitor,account,identity,images,imageandredirect;docs.comicine.comis the docs host, andfonts.comicine.comis backed by R2 - D1: database
limooo(APAC) is bound to the project asDB;schema_versionrecords applied migrations. Migration 022 is applied; migration 021 remains pending because it rewrites existing Apple Account identifiers.apple_accountsholds Apple Account rows, andblocked_ipsis the blocking authority; soft-deleted rows remain for audit - Secrets live under Pages โ Settings โ Environment variables โ Encrypt: the Turnstile pair,
GATE_HMAC_KEY,SESSION_HMAC_KEY,OBSERVABILITY_HMAC_KEY,VISITOR_IP_KEY,APPLE_ACCOUNT_ENCRYPTION_KEY,ACCESS_TEAM_DOMAINandACCESS_IDENTITY_AUD; the oldACCESS_ADMIN_AUDSandACCESS_VIEWER_AUDSsecrets remain configured but no runtime reads them, as doAPPLEID_ENCRYPTION_KEYand the twoAUTHENTIK_*entries - Access:
ComiCineprotects onlyidentity.comicine.com/login;visitor.comicine.comandaccount.comicine.comuse the shared identity handoff and their own host-only Pages sessions. The unusedComiCine-Adminapp remains configured foradmin.limooo.cn, which has no DNS record or Pages custom domain. There is no self-hosted IdP or custom login form - Workers run standalone:
limooo-status,limooo-blocklist-sync,limooo-d1-archiveandimage-watermark;status.comicine.comandsink.comicine.comare Worker custom domains - Cloudflare rules: the custom-firewall phase is empty (the
limooo_blocklistIP List is ready for a rule); the cache phase serves static assets, legacy image paths and watermark-Worker responses for a year while both HTML page-cache rules are disabled; the.comzone dynamic-redirect phase is empty; the legacy.cnzone sends GET/HEAD requests to matching.comhosts with 301 and other methods with 308, preserving path and query./feishu/<path>follows the same.cn301 tocomicine.com/feishu/<path>, then Pages forwards it tohttps://comicine.feishu.cn/<path>. - Smoke checks:
https://comicine.com/_healthโ 200,https://comicine.com/?challenge=1โ 403,https://docs.comicine.com/โ 302 tohttps://comicine.com,https://status.comicine.com/โ 200
Note: the ?challenge=1 403 confirms only that the gate page rendered; it does not prove Turnstile verification works. Complete a real browser challenge and confirm /gate/verify succeeds. The .com challenge is not yet restored.
The gate renders in place on every host, the real visitor IP is shown in the gate page and logs, and Access remains the only identity source.
License โ
Original materials owned and licensed by ComiCine are made available under the GNU Affero General Public License version 3 or any later version (AGPL-3.0-or-later). The GNU AGPL v3 text is in LICENSE.txt. Additional terms under Section 7 of GNU AGPL v3 are in ADDITIONAL_TERMS.txt. Those additional terms apply when the work is used under GNU AGPL v3; their application under a later version depends on that version's terms.
ComiCine is the project development brand and public attribution; copyright belongs to the actual rights holder. Preserve applicable author attributions and copyright notices, including reasonable source notices. Commercial use is permitted under the applicable AGPL version, subject to its terms and conditions.
For additional authorization or trademark permission inquiries, contact [email protected] (this legacy mailbox remains active). No additional authorization is required for commercial use that complies with the applicable GNU AGPL version.
See Third-Party Notices for identified third-party fonts, dependencies, and platform marks in QR-code images, and Copyright for copyright scope.