From d566333d0142b60cabe7e1f010796bbec0a4de2a Mon Sep 17 00:00:00 2001 From: Howie Young Date: Sat, 8 Aug 2026 18:00:36 +0800 Subject: [PATCH] Local archive showcase + open-source security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a browsable archive (典藏) served by the resident local receiver: each weekly email carries a tokened button to http://127.0.0.1:8787/archive, which lays out every past issue — rebuilt live from the DB, so even email-only issues get a full web view — like a magazine rack. Security foundation (safe under full source disclosure — Kerckhoffs): - server: exact Host allowlist (anti-DNS-rebinding) on all routes incl /capture; /capture requires application/json; typed responders + headersSent guard; /health de-fingerprinted to {ok:true}; testable createBrowstackServer(). - capability token: 256-bit CSPRNG in Keychain (browstack-archive), fail-closed, sha256 + timingSafeEqual, short-TTL cached; k -> cookie(sha256) -> 302, SameSite=Lax. - strict CSP (default-src 'none'; img-src 'self' data:), CORP, nosniff, no-referrer. - numeric-only /issues/:n and /covers/:n (no filename param); findCover exactOnly. - idempotent 0700/0600 data-file hardening on every DB open; ignore .env variants + local db files. - CI (npm ci -> typecheck -> node:test -> grep-gates), CODEOWNERS, SECURITY.md. Archive feature: - render/archive.ts rebuilds past issues from the issue window + issue_items + persisted summaries; render/issueView.ts is shared by preview and archive. - weekly reading digest (render/digest.ts): an editor-LLM distils the week's actual reading into one grounded line naming its subjects — the understanding that precedes the cover prompt. Stored in meta:issue_digest:N; shown as the issue epigraph, the archive-card subtitle, and the email lead-in. Each card also shows deep-read/social counts. - email button injected at send time only (token never written to out/). - npm run archive:open / token:rotate; heartbeat probes /health; install guard rejects a node whose better-sqlite3 ABI won't load (prevents silent crash-loop). - archive section mirrored across all six READMEs; AGENTS.md token + synthetic-sample rules. 18 hermetic security-invariant tests; verified end-to-end on the resident server. Co-Authored-By: Claude Opus 4.8 --- .github/CODEOWNERS | 13 +++ .github/workflows/ci.yml | 24 ++++ .gitignore | 8 ++ AGENTS.md | 7 ++ README.es.md | 10 +- README.fr.md | 10 +- README.ja.md | 10 +- README.ko.md | 10 +- README.md | 10 +- README.zh-TW.md | 10 +- SECURITY.md | 69 +++++++++++ package.json | 7 +- scripts/heartbeat.mjs | 29 +++++ scripts/install-weekly.mjs | 23 +++- scripts/security-gates.sh | 73 ++++++++++++ scripts/weekly.mjs | 4 +- src/archiveToken.ts | 86 ++++++++++++++ src/db.ts | 29 +++++ src/issue.ts | 13 ++- src/render/archive.ts | 159 +++++++++++++++++++++++++ src/render/digest.ts | 108 +++++++++++++++++ src/render/email.ts | 16 +-- src/render/issueView.ts | 228 ++++++++++++++++++++++++++++++++++++ src/render/preview.ts | 210 ++++------------------------------ src/render/send.ts | 11 ++ src/server.ts | 229 ++++++++++++++++++++++++++++++++----- src/shared/html.ts | 28 +++++ src/tools/archiveOpen.ts | 14 +++ src/tools/rotateToken.ts | 7 ++ test/security.test.ts | 190 ++++++++++++++++++++++++++++++ 30 files changed, 1414 insertions(+), 231 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml create mode 100644 SECURITY.md create mode 100644 scripts/security-gates.sh create mode 100644 src/archiveToken.ts create mode 100644 src/render/archive.ts create mode 100644 src/render/digest.ts create mode 100644 src/render/issueView.ts create mode 100644 src/shared/html.ts create mode 100644 src/tools/archiveOpen.ts create mode 100644 src/tools/rotateToken.ts create mode 100644 test/security.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..8ff95fc --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# Security-critical paths require a code-owner review before merge. +# These files carry the invariants documented in SECURITY.md; a subtle change here +# (loosening the Host check, binding 0.0.0.0, weakening the CSP, a default token, +# enabling jsdom scripts, or weakening .gitignore) can compromise every install. + +/src/server.ts @howieyoung +/src/shared/settings.ts @howieyoung +/src/fetch/extract.ts @howieyoung +/src/archiveToken.ts @howieyoung +/src/render/archive.ts @howieyoung +/.gitignore @howieyoung +/.github/ @howieyoung +/SECURITY.md @howieyoung diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5767a5c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + # 只用 npm ci(鎖定 lockfile)——絕不 npm install,避免 PR 悄悄改依賴/引入惡意套件 + - run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Security-invariant tests + run: npm test + - name: Security grep-gates + run: npm run security-gates diff --git a/.gitignore b/.gitignore index 96c7200..968c835 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,14 @@ out/ assets/covers/ src/shared/userConfig.ts .env +# `.env` 只精確比對,補上變體;任何位置的 SQLite 檔(含 debug 複本)都不進版控—— +# 貢獻者用自己真實的瀏覽歷史跑 pipeline,一個 git add -A 誤 commit 就是永久公開外洩。 +.env.* +*.db +*.db-wal +*.db-shm +*.sqlite +*.sqlite3 # Local tooling .claude/ diff --git a/AGENTS.md b/AGENTS.md index 86bfce0..2f82fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,13 @@ Everything runs locally; browsing data never leaves the machine. browsing history. Never send its contents anywhere. 4. **Never send email without the user asking.** `npm run send` actually sends. 5. macOS only (Keychain, launchd, sips). On other platforms, explain the limitation honestly. +6. **The archive capability token is the one secret of the local archive server.** It MUST be + CSPRNG-only (`crypto.randomBytes`), stored in the Keychain (`browstack-archive`), and **fail + closed** when absent. Never add a hardcoded/default/derived fallback, and never mint it inside an + HTTP request handler — only in the send/rotate path. (Enforced by CI + `SECURITY.md`.) +7. **Any committed sample/screenshot/cover must come from synthetic demo data, never a real DB.** + Rendered issues and covers are built from real browsing; a "demo" artifact generated from a live + DB would permanently publish someone's actual reading week. Regenerate from synthetic data if unsure. ## Onboarding flow (walk the user through, step by step) diff --git a/README.es.md b/README.es.md index b59a8e1..8c10ace 100644 --- a/README.es.md +++ b/README.es.md @@ -160,7 +160,15 @@ npm run schedule:weekly -- --day 1 --hour 9 # e.g. Mondays at 09:00 (--day 0 ### Números y archivo -Cada número está numerado y se conserva: №0 es el número de vista previa; a partir de ahí, cada número es simplemente №N — la progresión la lleva el propio número. Un `send` exitoso sella el número actual; la siguiente ejecución abre automáticamente uno nuevo con portada nueva. Los artefactos se acumulan en `out/` (versiones web + email por número) y `assets/covers/` (una portada por número), con un archivo navegable en `out/index.html`. Si la portada de una semana falla al renderizarse, se reutiliza la del número anterior. +Cada número está numerado y se conserva: №0 es el número de vista previa; a partir de ahí, cada número es simplemente №N — la progresión la lleva el propio número. Un `send` exitoso sella el número actual; la siguiente ejecución abre automáticamente uno nuevo con portada nueva. + +**Tu lectura, como una vitrina navegable.** Cada correo semanal incluye un botón **«Abre tu archivo»**. Enlaza a una página local — servida por el mismo receptor siempre activo en `127.0.0.1:8787` — que dispone cada número anterior como un expositor de revistas, reconstruido en vivo desde tu base de datos (así incluso los números que solo recibiste por correo tienen una versión web completa). Como el archivo se sirve desde tu propia máquina: + +- El botón funciona **solo en el mismo Mac, mientras el receptor esté en marcha** (instalado por `npm run schedule:weekly`). Es un enlace muerto en un teléfono o cualquier otro dispositivo — a propósito; tu lectura nunca sale de tu máquina. +- El enlace lleva un **capability token** — trátalo como una credencial de cuenta (consulta [SECURITY.md](SECURITY.md)). Puedes rotarlo cuando quieras con `npm run token:rotate` (los botones de correos antiguos dejan de funcionar; el siguiente número lleva uno nuevo). +- ¿Prefieres no rebuscar en el correo? `npm run archive:open` abre el archivo directamente en tu navegador. + +Los artefactos también se acumulan en disco en `out/` (versiones web + email por número) y `assets/covers/` (una portada por número), con un índice estático en `out/index.html`. Si la portada de una semana falla al renderizarse, el correo reutiliza la del número anterior; el archivo muestra la portada propia de cada número (o la predeterminada incluida), nunca la de otro número. ## Principios editoriales diff --git a/README.fr.md b/README.fr.md index a8e3a5f..d941d67 100644 --- a/README.fr.md +++ b/README.fr.md @@ -160,7 +160,15 @@ npm run schedule:weekly -- --day 1 --hour 9 # e.g. Mondays at 09:00 (--day 0 ### Numéros et archives -Chaque numéro est numéroté et conservé : №0 est le numéro d'aperçu ; ensuite, chaque numéro est simplement №N — la progression est portée par le numéro lui-même. Un `send` réussi scelle le numéro courant ; l'exécution suivante en ouvre automatiquement un nouveau avec une couverture neuve. Les artefacts s'accumulent dans `out/` (versions web + e-mail par numéro) et `assets/covers/` (une couverture par numéro), avec des archives consultables dans `out/index.html`. Si la couverture d'une semaine échoue au rendu, celle du numéro précédent est réutilisée. +Chaque numéro est numéroté et conservé : №0 est le numéro d'aperçu ; ensuite, chaque numéro est simplement №N — la progression est portée par le numéro lui-même. Un `send` réussi scelle le numéro courant ; l'exécution suivante en ouvre automatiquement un nouveau avec une couverture neuve. + +**Vos lectures, sous forme de vitrine consultable.** Chaque e-mail hebdomadaire comporte un bouton **« Ouvrir vos archives »**. Il pointe vers une page locale — servie par le même récepteur toujours actif sur `127.0.0.1:8787` — qui présente chaque numéro passé comme un présentoir à magazines, reconstruit en direct depuis votre base de données (ainsi même les numéros que vous n'avez reçus que par e-mail ont une version web complète). Comme les archives sont servies depuis votre propre machine : + +- Le bouton ne fonctionne **que sur le même Mac, tant que le récepteur tourne** (installé par `npm run schedule:weekly`). C'est un lien mort sur un téléphone ou tout autre appareil — c'est voulu ; vos lectures ne quittent jamais votre machine. +- Le lien porte un **capability token** — traitez-le comme un identifiant de compte (voir [SECURITY.md](SECURITY.md)). Vous pouvez le renouveler à tout moment avec `npm run token:rotate` (les boutons des anciens e-mails cessent de fonctionner ; le numéro suivant en porte un nouveau). +- Vous préférez ne pas fouiller vos e-mails ? `npm run archive:open` ouvre les archives directement dans votre navigateur. + +Les artefacts s'accumulent aussi sur le disque dans `out/` (versions web + e-mail par numéro) et `assets/covers/` (une couverture par numéro), avec un index statique dans `out/index.html`. Si la couverture d'une semaine échoue au rendu, l'e-mail réutilise celle du numéro précédent ; les archives affichent la couverture propre à chaque numéro (ou celle par défaut fournie), jamais celle d'un autre numéro. ## Principes éditoriaux diff --git a/README.ja.md b/README.ja.md index 98a0c8b..ed8ec16 100644 --- a/README.ja.md +++ b/README.ja.md @@ -160,7 +160,15 @@ npm run schedule:weekly -- --day 1 --hour 9 # e.g. Mondays at 09:00 (--day 0 ### 号数とアーカイブ -すべての号に番号が付き、保存されます:№0 はプレビュー号、それ以降の号はシンプルに №N——進行は号数そのものが伝えます。`send` の成功が現在の号を封緘し、次の実行は自動的に新しい号を新しい表紙で開きます。成果物は `out/`(号ごとの Web 版+メール版)と `assets/covers/`(号ごとに 1 枚の表紙)に蓄積され、`out/index.html` で閲覧可能なアーカイブになります。ある週の表紙レンダリングが失敗しても、前号の表紙が再利用されます。 +すべての号に番号が付き、保存されます:№0 はプレビュー号、それ以降の号はシンプルに №N——進行は号数そのものが伝えます。`send` の成功が現在の号を封緘し、次の実行は自動的に新しい号を新しい表紙で開きます。 + +**あなたの読書を、閲覧できるショーケースに。** 毎週のメールには **「アーカイブを開く」** ボタンが入っています。これは `127.0.0.1:8787` で常駐する同じ受信サービスが配信するローカルページへのリンクで、過去のすべての号を雑誌ラックのように並べ、あなたのデータベースからライブで再構築します(メールでしか受け取っていない号にも完全な Web 版が用意されます)。アーカイブはあなた自身のマシンから配信されるため: + +- このボタンは**同じ Mac 上で、受信サービスが動作している間だけ**機能します(`npm run schedule:weekly` でインストール)。スマートフォンやその他のデバイスではリンク切れになります——これは意図的な設計です。あなたの読書はマシンの外に出ません。 +- リンクには **capability token** が含まれます——アカウントの認証情報と同じように扱ってください([SECURITY.md](SECURITY.md) を参照)。`npm run token:rotate` でいつでもローテーションできます(古いメールのボタンは無効になり、次の号が新しいものを持ちます)。 +- メールを探すのが面倒?`npm run archive:open` でブラウザからアーカイブを直接開けます。 + +成果物はディスク上にも蓄積されます:`out/`(号ごとの Web 版+メール版)、`assets/covers/`(号ごとに 1 枚の表紙)、そして静的インデックス `out/index.html`。ある週の表紙レンダリングが失敗した場合、メールは前号の表紙を再利用します。アーカイブは各号自身の表紙(またはバンドルされたデフォルト)を表示し、他号の表紙を使うことはありません。 ## 編集原則 diff --git a/README.ko.md b/README.ko.md index 58d0033..b3b7b9b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -160,7 +160,15 @@ npm run schedule:weekly -- --day 1 --hour 9 # e.g. Mondays at 09:00 (--day 0 ### 호수와 아카이브 -모든 호에 번호가 붙고 보존됩니다: №0은 프리뷰호, 그 이후의 모든 호는 간단히 №N——진행은 호수 자체가 전달합니다. `send` 성공이 현재 호를 봉인하고, 다음 실행은 자동으로 새 표지와 함께 새 호를 엽니다. 결과물은 `out/`(호별 웹 + 이메일 버전)과 `assets/covers/`(호당 표지 1장)에 쌓이며, `out/index.html`에서 아카이브를 열람할 수 있습니다. 어느 주의 표지 렌더링이 실패해도 이전 호의 표지가 재사용됩니다. +모든 호에 번호가 붙고 보존됩니다: №0은 프리뷰호, 그 이후의 모든 호는 간단히 №N——진행은 호수 자체가 전달합니다. `send` 성공이 현재 호를 봉인하고, 다음 실행은 자동으로 새 표지와 함께 새 호를 엽니다. + +**당신의 읽기를 열람 가능한 쇼케이스로.** 매주 이메일에는 **"아카이브 열기"** 버튼이 들어 있습니다. `127.0.0.1:8787`에서 상주하는 동일한 수신 서비스가 제공하는 로컬 페이지로 연결되며, 지난 모든 호를 잡지 진열대처럼 배치하고 데이터베이스에서 실시간으로 다시 만듭니다(이메일로만 받은 호에도 완전한 웹 버전이 생깁니다). 아카이브는 당신 자신의 기기에서 제공되므로: + +- 이 버튼은 **같은 Mac에서, 수신 서비스가 실행 중일 때만** 작동합니다(`npm run schedule:weekly`로 설치). 휴대폰이나 다른 기기에서는 죽은 링크입니다——의도된 설계이며, 당신의 읽기는 기기를 떠나지 않습니다. +- 링크에는 **capability token**이 담깁니다——계정 자격 증명처럼 다루세요([SECURITY.md](SECURITY.md) 참고). `npm run token:rotate`로 언제든 교체할 수 있습니다(기존 이메일의 버튼은 무효화되고, 다음 호가 새 링크를 가집니다). +- 이메일을 뒤지기 번거롭다면? `npm run archive:open`으로 브라우저에서 아카이브를 바로 엽니다. + +결과물은 디스크에도 쌓입니다: `out/`(호별 웹 + 이메일 버전)과 `assets/covers/`(호당 표지 1장), 그리고 정적 인덱스 `out/index.html`. 어느 주의 표지 렌더링이 실패하면 이메일은 이전 호의 표지를 재사용하고, 아카이브는 각 호 자신의 표지(또는 번들 기본 표지)를 보여주며 다른 호의 표지를 쓰지 않습니다. ## 편집 원칙 diff --git a/README.md b/README.md index c8df18e..82cbd71 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,15 @@ npm run schedule:weekly -- --day 1 --hour 9 # e.g. Mondays at 09:00 (--day 0 ### Issues & archive -Every issue is numbered and kept: №0 is the preview issue; every issue after it is simply №N — progression is carried by the number itself. A successful `send` seals the current issue; the next run automatically opens a new one with a fresh cover. Artifacts accumulate under `out/` (web + email versions per issue) and `assets/covers/` (one cover per issue), with a browsable archive at `out/index.html`. If a week's cover fails to render, the previous issue's cover is reused. +Every issue is numbered and kept: №0 is the preview issue; every issue after it is simply №N — progression is carried by the number itself. A successful `send` seals the current issue; the next run automatically opens a new one with a fresh cover. + +**Your reading, as a browsable showcase.** Every weekly email carries an **"Open your archive"** button. It links to a local page — served by the same always-on receiver on `127.0.0.1:8787` — that lays out every past issue like a magazine rack, rebuilt live from your database (so even issues you only ever received by email get a full web view). Because the archive is served from your own machine: + +- The button works **only on the same Mac, while the receiver is running** (installed by `npm run schedule:weekly`). It is a dead link on a phone or any other device — by design; your reading never leaves your machine. +- The link carries a **capability token** — treat it like an account credential (see [SECURITY.md](SECURITY.md)). Rotate it any time with `npm run token:rotate` (old email buttons stop working; the next issue carries a fresh one). +- Prefer not to dig through email? `npm run archive:open` opens the archive in your browser directly. + +Artifacts also accumulate on disk under `out/` (web + email versions per issue) and `assets/covers/` (one cover per issue), with a static index at `out/index.html`. If a week's cover fails to render, the email reuses the previous issue's cover; the archive shows each issue's own cover (or the bundled default), never another issue's art. ## Editorial principles diff --git a/README.zh-TW.md b/README.zh-TW.md index 18d0272..995aa31 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -159,7 +159,15 @@ npm run schedule:weekly -- --day 1 --hour 9 # 例:每週一 09:00(--day 0 ### 期數與典藏 -每一期都有編號並永久留存:№0 是創刊預覽號,之後每一期以編號 №N 呈現——進展由期數本身傳達。`send` 成功即封刊,下一次產出自動開新的一期並生成新封面。刊物累積在 `out/`(每期的網頁版+email 版)、封面累積在 `assets/covers/`(每期一張),並有可瀏覽的典藏索引 `out/index.html`。某週封面渲染失敗時,沿用上一期封面、不擋出刊。 +每一期都有編號並永久留存:№0 是創刊預覽號,之後每一期以編號 №N 呈現——進展由期數本身傳達。`send` 成功即封刊,下一次產出自動開新的一期並生成新封面。 + +**你的閱讀,一座可瀏覽的櫥窗。** 每封每週刊物信裡都有一個 **「開啟你的典藏」** 按鈕,連到一個本機頁面——由那個常駐於 `127.0.0.1:8787` 的接收服務端出——像刊物櫥窗一樣陳列歷來每一期,並即時從你的資料庫重建(所以連只用 email 收過的期數也有完整網頁版)。因為典藏由你自己的機器端出: + +- 這個按鈕只在**同一台 Mac、且接收服務運行時**有效(由 `npm run schedule:weekly` 安裝)。在手機或任何其他裝置上都是死連結——這是刻意的設計;你的閱讀永不離開這台機器。 +- 連結帶有一個 **capability token**——請把它當成帳號憑證看待(見 [SECURITY.md](SECURITY.md))。隨時可用 `npm run token:rotate` 更新(舊信件的按鈕即刻失效,下一期會帶新的)。 +- 不想翻信件?`npm run archive:open` 直接在瀏覽器開啟典藏。 + +刊物同時也累積在磁碟上:`out/`(每期的網頁版+email 版)、`assets/covers/`(每期一張封面),並有靜態索引 `out/index.html`。某週封面渲染失敗時,email 沿用上一期封面;典藏則顯示各期自己的封面(或隨庫預設封面),絕不張冠李戴用到別期的插畫。 ## 編輯原則 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..511071c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +Browstack turns your own Chrome browsing history into a private weekly digest. +Everything runs locally; your browsing data never leaves your machine. Because the +project is open source, its security rests entirely on design that is safe **even +though an attacker can read every line of this code** — not on anything being secret. + +This document states honestly what Browstack protects, what it does not, and how to +report a problem. + +## Reporting a vulnerability + +Please report security issues **privately** to **howie@protico.io**. +Do **not** open a public GitHub issue with a working proof-of-concept — every user +runs a resident local server, so a public exploit puts the whole user base at risk +before a fix ships. We'll acknowledge and work with you on a coordinated disclosure. + +## Threat model — what is and isn't protected + +1. **Same-user code is out of scope (by design).** Any program running as your macOS + user account can read `data/browstack.db` (your history) and any local secret, + directly from disk. A local infostealer with your UID is not something a local + tool can defend against. Keep your machine free of malware. + +2. **The archive link's security equals your email account's security.** The weekly + email contains a link with a capability token (`?k=…`) that opens your local + archive. Anyone who can read that email can open the archive on your machine. + The token lives in your inbox (and Google's link-scanner logs / synced devices). + Treat it like an account credential. Rotate it any time with: + + ```bash + npm run token:rotate # old email links stop working; the next issue carries a fresh one + ``` + +3. **On a multi-user Mac, security depends on file permissions.** Browstack tightens + `data/`, `out/`, and `assets/covers/` to `0700` and the database and logs to `0600` + on every run, so other local accounts cannot read your history. If you loosen these + permissions, other users on the same Mac can read your data. + +4. **The archive adds a local attack surface that pure ingest did not have.** A + readable HTTP endpoint on `127.0.0.1:8787` now serves history-derived pages. It is + defended by: loopback-only bind, an exact `Host` allowlist (anti-DNS-rebinding), a + 256-bit capability token, and a strict `Content-Security-Policy`. These controls + must not be weakened. The email link only works **on the same Mac while the receiver + is running** — it is a dead link on a phone or any other device, by design. + +## Security invariants (enforced in CI) + +Changes to security-critical files (`src/server.ts`, `src/shared/settings.ts`, +`src/fetch/extract.ts`, `.gitignore`, and the archive modules) require a code-owner +review, and CI asserts the invariants below. Please do not "simplify" past them: + +- The server binds `127.0.0.1` only — never `0.0.0.0` or a configurable address. +- The `Host` check is an **exact** allowlist — never `includes`/`startsWith`/regex. +- The capability token is CSPRNG-only, compared in constant time, and **fails closed** + when absent — never a hardcoded/default/derived value, never minted in a request handler. +- The CSP has no `script-src` and no `unsafe-eval`; `default-src 'none'` stays. +- `POST /capture` requires `Content-Type: application/json`. +- `jsdom` parses hostile page HTML with inert defaults — never `runScripts` or + `resources: "usable"`. + +## Operational note + +The resident receiver (`com.browstack.serve`) is launched by launchd with a pinned +Node path. A Node upgrade (nvm/Homebrew) can invalidate that path and silently stop +the receiver — email links then fail to connect and captured reading is queued (and +eventually dropped past 300 items). The daily heartbeat probes `/health` and warns +via Notification Center if the receiver is down. If links stop working, re-run +`npm run schedule:weekly`. diff --git a/package.json b/package.json index eaa6150..43bf35f 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,19 @@ "reclassify": "tsx src/cli.ts reclassify", "serve": "tsx src/server.ts", "preview": "tsx src/render/preview.ts", + "archive:open": "tsx src/tools/archiveOpen.ts", + "token:rotate": "tsx src/tools/rotateToken.ts", "enrich": "tsx src/cli.ts enrich", "cover": "tsx src/render/cover.ts", + "digest": "tsx src/render/digest.ts", "email": "tsx src/render/email.ts", "send": "tsx src/render/email.ts && tsx src/render/send.ts", "weekly": "node scripts/weekly.mjs", "schedule:weekly": "node scripts/install-weekly.mjs", "build:ext": "esbuild extension/src/content.ts extension/src/background.ts extension/src/popup.ts --bundle --outdir=extension/dist --format=iife --target=chrome120 --log-level=warning", - "typecheck": "tsc --noEmit && tsc -p extension --noEmit" + "typecheck": "tsc --noEmit && tsc -p extension --noEmit", + "test": "node --import tsx --test test/*.test.ts", + "security-gates": "bash scripts/security-gates.sh" }, "dependencies": { "@mozilla/readability": "^0.5.0", diff --git a/scripts/heartbeat.mjs b/scripts/heartbeat.mjs index deb4bb8..ae51549 100644 --- a/scripts/heartbeat.mjs +++ b/scripts/heartbeat.mjs @@ -12,6 +12,35 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const okMarker = path.join(repoRoot, "data", "logs", ".heartbeat-was-ok"); const stamp = new Date().toString(); +// 接收服務健康檢查:裝了常駐 serve agent 卻連不上 127.0.0.1:8787 → 擷取資料正在流失,當天告警。 +// 沒裝 serve agent 的用戶不檢查(避免對只手動使用的人天天誤報)。 +const servePlist = path.join(home(), "Library", "LaunchAgents", "com.browstack.serve.plist"); +if (fs.existsSync(servePlist)) { + let serverOk = false; + try { + // 埠號與 src/shared/settings.ts 的 SHARED.serverPort 綁定(皆為 8787);若那裡改埠,這裡要一起改。 + const res = await fetch("http://127.0.0.1:8787/health", { signal: AbortSignal.timeout(2000) }); + serverOk = res.ok; + } catch { + serverOk = false; + } + if (!serverOk) { + console.error(`[heartbeat] ${stamp} — 接收服務 127.0.0.1:8787 無回應`); + try { + spawnSync("osascript", [ + "-e", + 'display notification "接收服務未運行——擷取資料可能流失。請重跑 npm run schedule:weekly,或檢查 data/logs/serve.log" with title "Browstack" sound name "Basso"', + ]); + } catch { + /* ignore */ + } + } +} + +function home() { + return process.env.HOME || ""; +} + // 沒有 claude CLI(用戶走 Anthropic API)→ 無憑證可保鮮,靜默結束 const which = spawnSync("which", ["claude"], { encoding: "utf8" }); if (which.status !== 0) { diff --git a/scripts/install-weekly.mjs b/scripts/install-weekly.mjs index 4238b5d..1840172 100644 --- a/scripts/install-weekly.mjs +++ b/scripts/install-weekly.mjs @@ -24,8 +24,27 @@ const label = "com.browstack.weekly"; const logDir = path.join(repoRoot, "data", "logs"); fs.mkdirSync(logDir, { recursive: true }); -// PATH 需含 node/npm 與 claude CLI(launchd 環境極簡) -const PATH = `${nodeDir}:/usr/local/bin:/usr/bin:/bin:${home}/.local/bin`; +// PATH 需含 node/npm 與 claude CLI(launchd 環境極簡);含 Apple Silicon 的 /opt/homebrew +const PATH = `${nodeDir}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:${home}/.local/bin`; + +// 前置檢查:better-sqlite3 的原生模組必須能在「即將被釘用的這個 node」下載入。 +// 版本不符(例如從 Node 22 shell 執行,但模組是為 Node 20 建置)會讓常駐 server 靜默 crash-loop、 +// 落地資料流失。與其之後才發現,不如現在就擋下並給出明確修法。 +// 必須實際建構一個 DB——原生 .node 是在 new Database() 時才 dlopen,單純 require 不會觸發、會誤判為通過。 +const probe = spawnSync(nodeBin, ["-e", "new (require('better-sqlite3'))(':memory:').close()"], { + cwd: repoRoot, + encoding: "utf8", +}); +if (probe.status !== 0) { + const hint = + (probe.stderr || "").split("\n").find((l) => /NODE_MODULE_VERSION|dlopen|better_sqlite3/i.test(l)) || + (probe.stderr || "").slice(0, 200); + console.error("⚠ better-sqlite3 無法在此 node 版本載入,若繼續安裝,常駐接收服務會無法啟動:"); + console.error(` node: ${nodeBin}`); + console.error(` ${hint.trim()}`); + console.error(" 修法:npm rebuild better-sqlite3 (或改用與模組建置版本相符的 node 再重跑本指令)"); + process.exit(1); +} // 出刊有兩個時段:主跑+ 12 小時後的當日重試(weekly.mjs 有冪等保護,成功後重試自動跳過) const retryHour = (hour + 12) % 24; diff --git a/scripts/security-gates.sh b/scripts/security-gates.sh new file mode 100644 index 0000000..6a43d22 --- /dev/null +++ b/scripts/security-gates.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# 安全不變式 grep 閘——任一命中即失敗。與 SECURITY.md 的「Security invariants」對應。 +# 開源專案的關鍵防迴歸:這些「看似無害」的改動,任一都可能危及每個安裝。 +# 本地執行:npm run security-gates +set -uo pipefail +cd "$(dirname "$0")/.." + +fail=0 + +# 命中 pattern 即失敗(用於「不該出現」的東西)。 +# 忽略純註解行(//、*、#)——說明文字可以提到這些字面,只有實際程式碼才算違規。 +deny() { + local desc="$1"; shift + local pattern="$1"; shift + local hits + hits="$(grep -rniE "$pattern" "$@" 2>/dev/null | grep -vE ':[0-9]+:[[:space:]]*(//|\*|#)' || true)" + if [ -n "$hits" ]; then + echo "✗ GATE FAILED: $desc" + echo "$hits" | sed 's/^/ /' + fail=1 + else + echo "✓ $desc" + fi +} + +# 綁定位址永遠本機迴環,絕不 0.0.0.0 +deny "server binds 127.0.0.1 only (no 0.0.0.0)" '0\.0\.0\.0' src/ +# CSP:default-src 'none' 已封殺腳本,不得再出現 script-src / unsafe-eval +deny "CSP has no script-src directive" 'script-src' src/ +deny "CSP has no unsafe-eval" 'unsafe-eval' src/ +# jsdom 必須維持惰性預設(解析敵意 HTML;啟用腳本/資源=RCE/SSRF) +deny "jsdom stays inert (no runScripts / resources:usable)" "runScripts|resources:[[:space:]]*[\"']usable" src/ +# server.ts 的 Host 反 rebinding 檢查必須精確比對,不得用寬鬆字串比對 +deny "server.ts Host check is exact (no includes/startsWith/endsWith)" '\.(includes|startsWith|endsWith)\(' src/server.ts + +# PR2 起 archiveToken.ts 若存在:token 比對必須用 timingSafeEqual、且無字面 default +if [ -f src/archiveToken.ts ]; then + if ! grep -q "timingSafeEqual" src/archiveToken.ts; then + echo "✗ GATE FAILED: archiveToken.ts must compare with crypto.timingSafeEqual" + fail=1 + else + echo "✓ archiveToken.ts uses timingSafeEqual" + fi + deny "archive token has no hardcoded/default fallback" 'archive[_-]?token[^\n]*(\|\||\?\?)[[:space:]]*[\"'\''`]' src/ +fi + +# 個人資料檔絕不進版控 +tracked="$(git ls-files -- data/ out/ assets/covers/ src/shared/userConfig.ts 2>/dev/null || true)" +if [ -n "$tracked" ]; then + echo "✗ GATE FAILED: personal files are tracked:" + echo "$tracked" | sed 's/^/ /' + fail=1 +else + echo "✓ no personal data files tracked" +fi + +# .gitignore 仍涵蓋所有敏感路徑 +for p in data/ out/ assets/covers/ src/shared/userConfig.ts .env; do + if git check-ignore -q "$p"; then + echo "✓ ignored: $p" + else + echo "✗ GATE FAILED: not ignored by .gitignore: $p" + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo "" + echo "Security gates failed. See SECURITY.md for the invariants these protect." + exit 1 +fi +echo "" +echo "All security gates passed." diff --git a/scripts/weekly.mjs b/scripts/weekly.mjs index fdc19bd..eda544a 100644 --- a/scripts/weekly.mjs +++ b/scripts/weekly.mjs @@ -1,4 +1,4 @@ -// 每週出刊:ingest → enrich → cover → send +// 每週出刊:ingest → enrich → cover → digest → send // 由 launchd 排程呼叫(npm run schedule:weekly 安裝,每週兩個時段:主跑+當日重試), // 也可手動 npm run weekly。 import { spawnSync } from "node:child_process"; @@ -58,5 +58,7 @@ run("ingest"); run("enrich", { tolerate: true }); // 封面渲染失敗(如金鑰未設)不擋出刊,沿用上一張封面 run("cover", { tolerate: true }); +// 當週閱讀速寫(典藏櫥窗副標):LLM 產出,失敗不擋出刊,該期就沒有速寫副標 +run("digest", { tolerate: true }); run("send"); console.log(`[weekly] 出刊完成 / done — ${new Date().toString()}`); diff --git a/src/archiveToken.ts b/src/archiveToken.ts new file mode 100644 index 0000000..bebd056 --- /dev/null +++ b/src/archiveToken.ts @@ -0,0 +1,86 @@ +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import { userInfo } from "node:os"; + +/** + * 典藏頁的 capability token——整套認證的唯一密鑰。 + * 開源前提:演算法全公開,安全只押在這顆每台各異的 256-bit 亂數上(Kerckhoffs)。 + * 存 macOS Keychain(service: browstack-archive),與 browstack-smtp / browstack-openai 一致, + * 不落在 data/ 或任何檔案裡——公開原始碼不會洩漏任何可用密鑰。 + * + * 鐵則(CI 與 CODEOWNERS 把關,見 SECURITY.md): + * - 只用 CSPRNG,絕無 hardcoded/預設/可推導的 token + * - 讀不到就 fail closed(handler 回 403),絕不在 HTTP handler 內 mint + * - 比對一律常數時間 + */ + +const SERVICE = "browstack-archive"; +const TOKEN_RE = /^[0-9a-f]{64}$/; // 32 bytes hex + +// 從 Keychain 讀 token;不存在/格式不符一律回 null(fail closed)。 +export function getArchiveToken(): string | null { + try { + const t = execFileSync("security", ["find-generic-password", "-s", SERVICE, "-w"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return TOKEN_RE.test(t) ? t : null; + } catch { + return null; // Keychain 無此項或非 macOS + } +} + +// 短 TTL 快取版:server 每個 archive 請求都要驗 token,若每次都 fork `security`, +// 一次索引頁(1 頁 + N 張封面)就同步 spawn N+1 次、阻塞事件迴圈,還會被未認證的洪水請求濫用來拖垮 /capture。 +// 快取數秒即可消除熱路徑上的子行程;rotate 後最多 TTL 秒生效(rotate 本就少見)。 +let tokenCache: { value: string | null; at: number } | null = null; +const TOKEN_CACHE_TTL_MS = 5000; +export function getArchiveTokenCached(nowMs: number = Date.now()): string | null { + if (tokenCache && nowMs - tokenCache.at < TOKEN_CACHE_TTL_MS) return tokenCache.value; + const value = getArchiveToken(); + tokenCache = { value, at: nowMs }; + return value; +} + +// 產生新 token 並寫入 Keychain。只由 render/rotate 流程呼叫,永遠不在 HTTP handler 內。 +export function rotateArchiveToken(): string { + const token = crypto.randomBytes(32).toString("hex"); + execFileSync( + "security", + ["add-generic-password", "-s", SERVICE, "-a", userInfo().username, "-w", token, "-U"], + { stdio: ["ignore", "ignore", "ignore"] }, + ); + return token; +} + +// 取得可用 token:有就用,沒有就產生(供 send/rotate 呼叫;非 handler,故允許 mint)。 +export function ensureArchiveToken(): string { + return getArchiveToken() ?? rotateArchiveToken(); +} + +// 先驗兩者格式為固定長度,再 sha256 後常數時間比對(timingSafeEqual 長度不等會 throw,故先 hash)。 +function constantTimeEqual(a: string, b: string): boolean { + const ha = crypto.createHash("sha256").update(a).digest(); + const hb = crypto.createHash("sha256").update(b).digest(); + return crypto.timingSafeEqual(ha, hb); +} + +// 驗證信件連結帶來的 ?k=。stored 缺失/格式錯 → false(fail closed)。 +export function checkArchiveKey(presented: string | null | undefined, stored: string | null): boolean { + if (!stored || !TOKEN_RE.test(stored)) return false; + if (typeof presented !== "string" || presented.length === 0) return false; + return constantTimeEqual(presented, stored); +} + +// 由 token 衍生的 session cookie 值:是 token 的 sha256,不是 token 本身。 +// 無狀態、KeepAlive 重啟後仍有效;萬一外洩到同機其他 loopback port,它也不能還原成 token。 +export function sessionCookieValue(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +// 驗證 cookie:比對「cookie 值」與「由 stored token 衍生的期望值」。 +export function checkSessionCookie(cookieVal: string | null | undefined, stored: string | null): boolean { + if (!stored || !TOKEN_RE.test(stored)) return false; + if (typeof cookieVal !== "string" || cookieVal.length === 0) return false; + return constantTimeEqual(cookieVal, sessionCookieValue(stored)); +} diff --git a/src/db.ts b/src/db.ts index c0e7a66..3253890 100644 --- a/src/db.ts +++ b/src/db.ts @@ -28,6 +28,7 @@ export function getDb(): Database.Database { fs.mkdirSync(CONFIG.dataDir, { recursive: true }); db = new Database(path.join(CONFIG.dataDir, "browstack.db")); db.pragma("journal_mode = WAL"); + hardenPerms(); db.exec(` CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY, @@ -110,6 +111,34 @@ function migrate(db: Database.Database): void { addColumn("published_in", "published_in INTEGER"); } +/** + * 冪等收緊本機資料檔權限——不只在建立時,每次開 DB 都跑一遍, + * 才能一併修好既有安裝的舊檔(多數是 umask 022 留下的 0644,同機其他 OS 帳號可讀)。 + * data/、out/、assets/covers/ → 0700;DB(含 -wal/-shm)與 logs → 0600。best-effort,失敗不擋。 + */ +export function hardenPerms(): void { + const root = path.join(CONFIG.dataDir, ".."); + const chmodSafe = (p: string, mode: number) => { + try { + if (fs.existsSync(p)) fs.chmodSync(p, mode); + } catch { + // 權限無法變更(唯讀 volume 等)時不擋流程 + } + }; + for (const dir of [CONFIG.dataDir, path.join(root, "out"), path.join(root, "assets", "covers")]) { + chmodSafe(dir, 0o700); + } + const dbFile = path.join(CONFIG.dataDir, "browstack.db"); + for (const f of [dbFile, `${dbFile}-wal`, `${dbFile}-shm`]) chmodSafe(f, 0o600); + const logsDir = path.join(CONFIG.dataDir, "logs"); + chmodSafe(logsDir, 0o700); + try { + for (const f of fs.readdirSync(logsDir)) chmodSafe(path.join(logsDir, f), 0o600); + } catch { + // logs/ 尚未建立 + } +} + export function getMeta(key: string): string | null { const row = getDb().prepare("SELECT value FROM meta WHERE key = ?").get(key) as | { value: string } diff --git a/src/issue.ts b/src/issue.ts index 385893b..b84efd9 100644 --- a/src/issue.ts +++ b/src/issue.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type Database from "better-sqlite3"; import { CONFIG } from "./config.js"; -import { getDb } from "./db.js"; +import { getDb, getMeta } from "./db.js"; /** * 期數與典藏:每一期有自己的編號、刊名、週期區間與封面。 @@ -75,13 +75,20 @@ export function listIssues(): Issue[] { return db.prepare("SELECT * FROM issues ORDER BY number DESC").all() as Issue[]; } +// 當週閱讀速寫:生成封面 prompt 之前對本週閱讀內容的一句編輯理解(由 render/digest.ts 產生,存 meta)。 +// 供刊頭(issueView)、典藏櫥窗、信件引言共用。沒有就回 null。 +export function issueDigest(n: number): string | null { + const d = getMeta(`issue_digest:${n}`); + return d && d.trim().length > 0 ? d.trim() : null; +} + /** * 本期封面檔案:優先 issue-N.(png|jpg|svg) → 最近一期的封面(點陣圖優先) * → 隨庫附帶的預設封面(assets/cover-default.jpg,即創刊號封面)。 * 全新 clone 尚未跑過 cover、或某週渲染失敗時,都能有一張完整封面,不擋出刊。 * rasterOnly:email 的 CID 內嵌只吃點陣圖(png/jpg),svg 僅網頁版可用。 */ -export function findCover(n: number, opts: { rasterOnly?: boolean } = {}): string | null { +export function findCover(n: number, opts: { rasterOnly?: boolean; exactOnly?: boolean } = {}): string | null { const exts = opts.rasterOnly ? (["png", "jpg"] as const) : (["png", "jpg", "svg"] as const); const assetsRoot = path.join(CONFIG.dataDir, "..", "assets"); const dir = path.join(assetsRoot, "covers"); @@ -93,6 +100,8 @@ export function findCover(n: number, opts: { rasterOnly?: boolean } = {}): strin const exact = path.join(dir, `issue-${n}.${ext}`); if (fs.existsSync(exact)) return exact; } + // exactOnly:典藏頁需忠實呈現——沒有本期封面就退回預設封面,絕不借用其他期的插畫張冠李戴 + if (opts.exactOnly) return defaultCover; if (!fs.existsSync(dir)) return orDefault(null); const pattern = opts.rasterOnly ? /^issue-\d+\.(png|jpg)$/ : /^issue-\d+\.(png|jpg|svg)$/; const num = (f: string) => Number(f.match(/^issue-(\d+)\./)?.[1] ?? -1); diff --git a/src/render/archive.ts b/src/render/archive.ts new file mode 100644 index 0000000..90533fa --- /dev/null +++ b/src/render/archive.ts @@ -0,0 +1,159 @@ +import { getDb } from "../db.js"; +import { type Issue, issueDigest, listIssues } from "../issue.js"; +import { esc } from "../shared/html.js"; +import { renderIssueDocument, type IssueStats } from "./issueView.js"; +import type { IssueItem } from "./select.js"; + +/** + * 典藏頁的即時渲染(server 端):整個櫥窗與每一期都由當前 DB 重建,不落地檔案。 + * 過刊(含只寄過 email、沒網頁版的期數)由 issues 週期 + issue_items + 已持久化的 pages.summary + * 忠實重建;訊號(分鐘/實讀)以該期 stored 週窗重算,封面走同源 /covers/N。 + */ + +const CHROME_EPOCH_OFFSET_SEC = 11_644_473_600; +const toChromeTime = (unixSec: number) => (unixSec + CHROME_EPOCH_OFFSET_SEC) * 1_000_000; +const fmtDate = (sec: number) => { + const d = new Date(sec * 1000); + return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; +}; + +// 依 stored 週窗 [start,end] 重算某期某類的入選項目(訊號以窗內造訪計算,與當初出刊一致)。 +function reconstructItems(n: number, kind: "article" | "social", order: string): IssueItem[] { + const db = getDb(); + const issue = db.prepare("SELECT week_start, week_end FROM issues WHERE number = ?").get(n) as + | { week_start: number; week_end: number } + | undefined; + if (!issue) return []; + const sc = toChromeTime(issue.week_start); + const ec = toChromeTime(issue.week_end); + return db + .prepare( + `SELECT p.id, p.title, p.url, p.topic, p.summary, p.devices, p.total_visits, + ROUND(COALESCE((SELECT SUM(v.duration_sec) FROM visits_log v + WHERE v.page_id = p.id AND v.visit_time > ? AND v.visit_time <= ?), 0) / 60.0, 1) AS minutes, + COALESCE((SELECT MAX(v.duration_sec) FROM visits_log v + WHERE v.page_id = p.id AND v.visit_time > ? AND v.visit_time <= ?), 0) >= 1200 AS capped, + ROUND(COALESCE((SELECT SUM(c.active_seconds) FROM captures c + WHERE c.url = p.url AND c.captured_at > ? AND c.captured_at <= ?), 0) / 60.0, 1) AS active_min + FROM pages p JOIN issue_items ii ON ii.page_id = p.id + WHERE ii.issue_number = ? AND p.kind = ? AND p.summary IS NOT NULL AND p.title IS NOT NULL + ORDER BY ${order}`, + ) + .all(sc, ec, sc, ec, issue.week_start, issue.week_end, n, kind) as IssueItem[]; +} + +function statsForWindow(startUnix: number, endUnix: number): IssueStats { + const db = getDb(); + const sc = toChromeTime(startUnix); + const ec = toChromeTime(endUnix); + const footprint = db + .prepare("SELECT COUNT(*) AS visits FROM visits_log WHERE visit_time > ? AND visit_time <= ?") + .get(sc, ec) as { visits: number }; + const reading = db + .prepare( + `SELECT COUNT(DISTINCT p.id) AS pages, ROUND(COALESCE(SUM(v.duration_sec), 0) / 60.0) AS minutes + FROM visits_log v JOIN pages p ON p.id = v.page_id + WHERE v.visit_time > ? AND v.visit_time <= ? AND p.kind IN ('article', 'social')`, + ) + .get(sc, ec) as { pages: number; minutes: number }; + const deviceSplit = db + .prepare("SELECT v.device, COUNT(*) AS n FROM visits_log v WHERE v.visit_time > ? AND v.visit_time <= ? GROUP BY v.device") + .all(sc, ec) as Array<{ device: string; n: number }>; + const mobileVisits = deviceSplit.find((d) => d.device === "mobile")?.n ?? 0; + const totalVisits = deviceSplit.reduce((a, d) => a + d.n, 0); + return { + footprintVisits: footprint.visits, + mobileVisits, + totalVisits, + readingPages: reading.pages, + readingMinutes: Math.round(reading.minutes ?? 0), + }; +} + +// 該期入選則數(櫥窗副標用)。№0 等無 issue_items 者回 0/0。 +function issueCounts(n: number): { articles: number; social: number } { + const row = getDb() + .prepare( + `SELECT + SUM(CASE WHEN p.kind = 'article' THEN 1 ELSE 0 END) AS articles, + SUM(CASE WHEN p.kind = 'social' THEN 1 ELSE 0 END) AS social + FROM issue_items ii JOIN pages p ON p.id = ii.page_id + WHERE ii.issue_number = ?`, + ) + .get(n) as { articles: number | null; social: number | null }; + return { articles: row.articles ?? 0, social: row.social ?? 0 }; +} + +// 單期網頁(由 DB 重建)。查無此期回 null(→ server 404)。 +export function renderIssuePage(n: number): string | null { + const db = getDb(); + const issue = db.prepare("SELECT * FROM issues WHERE number = ?").get(n) as Issue | undefined; + if (!issue) return null; + const articles = reconstructItems(n, "article", "active_min DESC, minutes DESC"); + const socialPosts = reconstructItems(n, "social", "minutes DESC"); + const stats = statsForWindow(issue.week_start, issue.week_end); + // 封面走同源路由(findCover exactOnly:本期封面或預設,絕不借用他期) + const coverHtml = `第 ${n} 期封面插畫`; + return renderIssueDocument({ issue, articles, socialPosts, stats, coverHtml, digest: issueDigest(n) }); +} + +// 典藏櫥窗索引(由 listIssues 即時生成,連結走 /issues/N、封面走 /covers/N)。 +export function renderArchiveIndex(): string { + const cards = listIssues() + .map((i) => { + const label = i.title ? `№${i.number} · ${esc(i.title)}` : `№${i.number}`; + const status = i.sent_at ? `已寄出 ${fmtDate(i.sent_at)}` : "編輯中"; + const { articles, social } = issueCounts(i.number); + const digest = issueDigest(i.number); + // 當週閱讀速寫:這一期「你在讀什麼、在想什麼」的一句編輯理解 + const digestHtml = digest ? `${esc(digest)}` : ""; + // 統計副標:幾篇深讀、幾則社群迴響 + const statsHtml = + articles + social > 0 ? `${articles} 篇深讀 · ${social} 則社群迴響` : ""; + return ` +
${esc(label)} 封面
+
${label}${digestHtml}${statsHtml}${fmtDate(i.week_start)} — ${fmtDate(i.week_end)} · ${status}
+
`; + }) + .join("\n"); + return ` + + + + +Browstack 典藏 + + + +
+

Browstack

典藏 · Your Personal Weekly Digest
+
+${cards} +
+
資料未離開這台機器 · PUBLISHED FOR AN AUDIENCE OF ONE
+
+ +`; +} diff --git a/src/render/digest.ts b/src/render/digest.ts new file mode 100644 index 0000000..0af4df5 --- /dev/null +++ b/src/render/digest.ts @@ -0,0 +1,108 @@ +import { getDb, setMeta } from "../db.js"; +import { getCurrentIssue } from "../issue.js"; +import { getProvider } from "../llm/provider.js"; +import { selectIssueItems, type IssueItem } from "./select.js"; + +/** + * 當週閱讀速寫:在生成封面 prompt「之前」,對讀者本週實際讀進去的內容做一句精闢的編輯理解—— + * 反映「這個人這週在追什麼、被什麼吸引」,而不是內容清單、也不是封面畫面的描述。 + * 存進 meta(key: issue_digest:N),供典藏櫥窗當副標。 + * + * 用法: + * tsx src/render/digest.ts # 當期(用 selectIssueItems 的即時選材) + * tsx src/render/digest.ts # 指定期(由 issue_items 重建,用於回填過刊) + */ + +interface Seed { + topic: string | null; + title: string; + note?: string; // 文章的 takeaway 或社群的 context——讓 LLM 讀到內容的實質,不只標題 + kind: string; +} + +const cleanTitle = (s: string) => s.replace(/^\(\d+\)\s*/, "").replace(/\s*[||].*$/, "").trim(); + +function noteOf(summary: string | null): string | undefined { + try { + const s = JSON.parse(summary ?? "{}") as { takeaway?: string; context?: string }; + return s.takeaway ?? s.context; + } catch { + return undefined; + } +} + +const seedOf = (i: IssueItem, kind: string): Seed => ({ + topic: i.topic, + title: cleanTitle(i.title), + note: noteOf(i.summary), + kind, +}); + +function currentSeeds(): Seed[] { + const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400; + const { articles, socialPosts } = selectIssueItems(weekAgo); + return [...articles.map((i) => seedOf(i, "article")), ...socialPosts.map((i) => seedOf(i, "social"))]; +} + +function issueSeeds(n: number): Seed[] { + const rows = getDb() + .prepare( + `SELECT p.title, p.topic, p.kind, p.summary + FROM issue_items ii JOIN pages p ON p.id = ii.page_id + WHERE ii.issue_number = ? AND p.title IS NOT NULL AND p.summary IS NOT NULL + ORDER BY p.kind DESC`, + ) + .all(n) as Array<{ title: string; topic: string | null; kind: string; summary: string }>; + return rows.map((r) => ({ topic: r.topic, title: cleanTitle(r.title), note: noteOf(r.summary), kind: r.kind })); +} + +const arg = process.argv[2]; +const issueNo = arg !== undefined ? Number(arg) : getCurrentIssue().number; +if (!Number.isInteger(issueNo) || issueNo < 0) { + console.error(`期數不合法:${arg}`); + process.exit(1); +} +const seeds = arg !== undefined ? issueSeeds(issueNo) : currentSeeds(); +if (seeds.length === 0) { + console.log(`第 ${issueNo} 期無入選內容,略過閱讀速寫`); + process.exit(0); +} + +const provider = getProvider(); +const reply = await provider.complete({ + system: + "你是個人週刊《Browstack》的主編,為本期寫一句「當週閱讀速寫」。" + + "你會拿到讀者本週真正讀進去的內容(主題、標題、每篇重點)。" + + "請寫『一句』繁體中文,自然帶出這週閱讀的幾個具體題材/主體(點名真實的主題、領域、關鍵概念)," + + "讓讀者一眼就認出自己讀了什麼、重心落在哪。要具體、扣著真實內容;" + + "不要空泛的格言或硬擠的洞察,不要賣弄機智,也不要描述封面畫面。", + prompt: + `本週讀者實際讀進去的內容:\n${JSON.stringify(seeds, null, 1)}\n\n` + + `輸出一句繁體中文速寫,約 22–38 字,最多帶出 2–3 個最有份量的具體題材/關鍵字(最重要的放前面,不必全列),` + + `讀起來像主編為這一期下的一句引言。只輸出這句話:不要引號、不要標籤前綴、不要條列。`, + maxTokens: 300, +}); + +// 版面安全網:超過上限時在最近的斷句處收尾,不硬切在詞中間(正常情況 prompt 已把長度控在 ~40 字內) +function clip(s: string, max: number): string { + if (s.length <= max) return s; + const head = s.slice(0, max); + const brk = Math.max(head.lastIndexOf("、"), head.lastIndexOf(","), head.lastIndexOf(";"), head.lastIndexOf(" ")); + return (brk > max * 0.5 ? head.slice(0, brk) : head).trim(); +} + +// 清掉可能的圍欄、首尾引號、多餘空白 +const cleaned = reply + .replace(/```/g, "") + .trim() + .replace(/^[「『"']+|[」』"']+$/g, "") + .replace(/\s+/g, " ") + .trim(); +const digest = clip(cleaned, 54); + +if (!digest) { + console.error("閱讀速寫生成為空,未寫入"); + process.exit(1); +} +setMeta(`issue_digest:${issueNo}`, digest); +console.log(`已寫入第 ${issueNo} 期閱讀速寫(${digest.length} 字)`); // 不印內容——屬個人閱讀衍生資料 diff --git a/src/render/email.ts b/src/render/email.ts index f786148..2972f02 100644 --- a/src/render/email.ts +++ b/src/render/email.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { CONFIG } from "../config.js"; import { getDb } from "../db.js"; -import { getCurrentIssue } from "../issue.js"; +import { getCurrentIssue, issueDigest } from "../issue.js"; +import { esc, safeHref } from "../shared/html.js"; import { selectIssueItems, type IssueItem } from "./select.js"; /** @@ -16,6 +17,7 @@ const db = getDb(); const now = Math.floor(Date.now() / 1000); const weekAgo = now - DAYS * 86400; const issue = getCurrentIssue(); +const digest = issueDigest(issue.number); // 當週引言(沒有就不顯示) const { articles, socialPosts } = selectIssueItems(weekAgo); @@ -32,8 +34,6 @@ db.transaction(() => { for (const item of [...articles, ...socialPosts]) ins.run(issue.number, item.id); })(); -const esc = (s: string) => - s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); const cleanTitle = (s: string) => esc(s.replace(/^\(\d+\)\s*/, "").replace(/\s*[||].*$/, "").trim()); const fmtDate = (sec: number) => { const d = new Date(sec * 1000); @@ -76,7 +76,7 @@ const articleHtml = [...groups.entries()] return `
${String(rank).padStart(2, "0")}
- ${cleanTitle(a.title)} + ${cleanTitle(a.title)}
    ${bullets}
${s.takeaway ? `
◈ ${esc(s.takeaway)}
` : ""}
${hostOf(a.url)} · ${signalLabel(a)}
@@ -94,7 +94,7 @@ const socialHtml = socialPosts
${s.context ? `
${esc(s.context)}
` : ""}
${esc(p.title.replace(/^\(\d+\)\s*/, "").trim().slice(0, 220))}${p.title.length > 220 ? "…" : ""}
-
${signalLabel(p)} · 查看原文 →
+
${signalLabel(p)} · 查看原文 →
`; }) .join("\n"); @@ -109,8 +109,9 @@ const html = `
Your Personal Weekly Digest
-
-
本期選輯自你過去七天的瀏覽足跡——${articles.length} 篇深讀與 ${socialPosts.length} 則社群迴響,附編輯摘要。
+
+ ${digest ? `
${esc(digest)}
` : ""} +
本期選輯自你過去七天的瀏覽足跡——${articles.length} 篇深讀與 ${socialPosts.length} 則社群迴響,附編輯摘要。
01 · 本週深讀
@@ -120,6 +121,7 @@ const html = `
02 · 社群迴響
${socialHtml}
+
BROWSTACK №${issue.number} · 由你的瀏覽紀錄自動編輯
資料未離開你的機器 · PUBLISHED FOR AN AUDIENCE OF ONE
diff --git a/src/render/issueView.ts b/src/render/issueView.ts new file mode 100644 index 0000000..fc56e33 --- /dev/null +++ b/src/render/issueView.ts @@ -0,0 +1,228 @@ +import { esc, safeHref } from "../shared/html.js"; +import type { IssueItem } from "./select.js"; + +/** + * 單期網頁版的唯一渲染來源——preview(當前期,即時資料)與 archive(過刊,由 DB 重建)共用, + * 確保兩者外觀完全一致(這正是抽出本模組的目的:兩版永不分岔)。 + * cover 的嵌入方式由呼叫端決定(preview 用 data-URI/內嵌 SVG;archive 用同源 /covers/N), + * 以 coverHtml 參數傳入。 + */ + +export interface IssueStats { + footprintVisits: number; + mobileVisits: number; + totalVisits: number; + readingPages: number; + readingMinutes: number; +} + +// 摘要是 enrich 寫入的 JSON;仍以 try/catch 防禦——單一列的損毀/舊格式摘要 +// 不該讓整個典藏頁 500/400,退化成空卡片即可。 +function parseSummary(s: string | null): T { + try { + return JSON.parse(s ?? "{}") as T; + } catch { + return {} as T; + } +} + +const cleanTitle = (s: string) => esc(s.replace(/^\(\d+\)\s*/, "").replace(/\s*[||].*$/, "").trim()); +const deviceLabel = (d: string) => (d === "both" ? "桌機+手機" : d === "mobile" ? "手機" : "桌機"); +// 你當時讀了多久——這就是它被選進本期的原因 +const signalLabel = (i: IssueItem) => + i.active_min > 0 + ? `⚡ 本週你實讀了 ${i.active_min} 分鐘` + : `本週你停留了 ${i.minutes}${i.capped ? "+" : ""} 分鐘`; +const fmtDate = (sec: number) => { + const d = new Date(sec * 1000); + return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; +}; +const hostOf = (u: string) => { + try { + return new URL(u).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +}; +const sourceOf = (u: string) => + /threads\./.test(u) ? "Threads" : /facebook\./.test(u) ? "Facebook" : /linkedin\./.test(u) ? "LinkedIn" : "社群"; + +// 01 · 本週深讀:依主題分組,組內卡片,全域流水編號 +function renderArticles(articles: IssueItem[]): string { + const topicGroups = new Map(); + for (const a of articles) { + const key = a.topic ?? "其他"; + if (!topicGroups.has(key)) topicGroups.set(key, []); + topicGroups.get(key)!.push(a); + } + let rank = 0; + return [...topicGroups.entries()] + .map(([topic, items]) => { + const cards = items + .map((a) => { + rank++; + const s = parseSummary<{ bullets?: string[]; takeaway?: string }>(a.summary); + const bullets = (s.bullets ?? []).map((b) => `
  • ${esc(b)}
  • `).join(""); + return ` +
    +
    ${String(rank).padStart(2, "0")}
    +
    + ${cleanTitle(a.title)} +
      ${bullets}
    + ${s.takeaway ? `
    ◈ ${esc(s.takeaway)}
    ` : ""} +
    ${hostOf(a.url)} · ${signalLabel(a)} · ${deviceLabel(a.devices)}
    +
    +
    `; + }) + .join("\n"); + return `

    ${esc(topic)}

    \n${cards}`; + }) + .join("\n"); +} + +// 02 · 社群迴響 +function renderSocial(socialPosts: IssueItem[]): string { + return socialPosts + .map((p) => { + const s = parseSummary<{ context?: string }>(p.summary); + return ` +
    + ${s.context ? `
    ${esc(s.context)}
    ` : ""} +
    ${esc(p.title.replace(/^\(\d+\)\s*/, "").trim())}
    +
    ${sourceOf(p.url)} ${signalLabel(p)} · 查看原文
    +
    `; + }) + .join("\n"); +} + +const STYLE = ` + :root { + --paper: #faf6ee; --paper-deep: #f1ebdd; --ink: #211c15; --muted: #8d8474; + --accent: #b5361c; --rule: #d9d2c2; + } + * { box-sizing: border-box; margin: 0; } + body { background: #e6e1d5; font-family: "PingFang TC", "Noto Sans TC", sans-serif; color: var(--ink); } + .sheet { max-width: 760px; margin: 40px auto; background: var(--paper); box-shadow: 0 2px 40px rgba(60,50,30,.18); } + + .nameplate { padding: 30px 48px 22px; text-align: center; } + .np-row { display: flex; justify-content: space-between; align-items: baseline; + font-family: "Noto Serif TC", serif; font-size: 14px; font-weight: 700; + color: var(--accent); letter-spacing: .12em; } + .np-title { font-family: "Noto Serif TC", "Songti TC", serif; font-style: italic; + font-weight: 900; font-size: 60px; line-height: 1.05; color: var(--ink); margin-top: 4px; } + .np-tagline { margin-top: 10px; font-size: 11px; letter-spacing: .48em; + color: var(--muted); text-transform: uppercase; } + .cover-art { line-height: 0; border-top: 3px double var(--rule); border-bottom: 3px double var(--rule); } + .cover-art svg, .cover-art img { width: 100%; height: auto; display: block; } + .cover-info { padding: 26px 64px 34px; border-bottom: 3px double var(--rule); } + .issue-digest { font-family: "Noto Serif TC", "Songti TC", serif; font-style: italic; font-size: 19px; + line-height: 1.7; color: var(--ink); max-width: 32em; } + .issue-note { margin-top: 16px; font-size: 14px; line-height: 1.9; max-width: 34em; color: var(--muted); } + .stat-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 24px; + border-top: 1px solid var(--rule); padding-top: 18px; gap: 12px; } + .stat b { display: block; font-family: "Noto Serif TC", serif; font-size: 28px; font-weight: 700; } + .stat span { font-size: 12px; color: var(--muted); letter-spacing: .15em; } + + section { padding: 44px 64px; } + section + section { border-top: 1px solid var(--rule); } + h2 { font-size: 13px; letter-spacing: .4em; color: var(--accent); font-weight: 600; margin-bottom: 6px; } + .section-note { font-size: 12px; color: var(--muted); margin-bottom: 24px; line-height: 1.8; } + .topic { font-family: "Noto Serif TC", serif; font-size: 15px; letter-spacing: .25em; + color: var(--ink); margin: 26px 0 4px; padding-bottom: 6px; border-bottom: 2px solid var(--ink); display: inline-block; } + + .item { display: grid; grid-template-columns: 56px 1fr; gap: 14px; padding: 18px 0; } + .item + .item { border-top: 1px dotted var(--rule); } + .rank { font-family: "Noto Serif TC", serif; font-size: 26px; font-weight: 700; color: var(--accent); opacity: .85; } + .item-title { font-family: "Noto Serif TC", "Songti TC", serif; font-size: 20px; font-weight: 700; + color: var(--ink); text-decoration: none; line-height: 1.55; display: block; } + .item-title:hover { color: var(--accent); } + .sum { margin: 12px 0 0; padding-left: 18px; } + .sum li { font-size: 14px; line-height: 1.9; margin-bottom: 4px; } + .takeaway { margin-top: 10px; font-family: "Noto Serif TC", serif; font-size: 14px; + color: var(--accent); line-height: 1.7; } + .item-meta { margin-top: 10px; font-size: 12px; color: var(--muted); } + + .quote { background: var(--paper-deep); border-left: 3px solid var(--accent); + padding: 18px 24px 16px 22px; margin: 0 0 18px; } + .quote-context { font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; line-height: 1.7; } + .quote-text { font-family: "Noto Serif TC", "Songti TC", serif; font-size: 15px; line-height: 2; + display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; } + .quote-meta { margin-top: 12px; font-size: 12px; color: var(--muted); } + .quote-meta a { color: var(--accent); } + .badge { border: 1px solid var(--rule); padding: 1px 8px; border-radius: 10px; margin-right: 6px; } + + .figures { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 32px; font-size: 14px; line-height: 2.1; } + .figures b { font-family: "Noto Serif TC", serif; } + + .colophon { text-align: center; padding: 36px 64px 44px; border-top: 3px double var(--rule); + font-size: 11px; letter-spacing: .3em; color: var(--muted); line-height: 2.4; }`; + +// 完整單期網頁。coverHtml 由呼叫端提供(preview:data-URI/SVG;archive:/covers/N)。 +export function renderIssueDocument(params: { + issue: { number: number; title: string; week_start: number; week_end: number }; + articles: IssueItem[]; + socialPosts: IssueItem[]; + stats: IssueStats; + coverHtml: string; + digest?: string | null; +}): string { + const { issue, articles, socialPosts, stats, coverHtml, digest } = params; + const digestHtml = digest ? `

    ${esc(digest)}

    ` : ""; + const issueLabel = issue.title ? `№${issue.number} · ${issue.title}` : `№${issue.number}`; + const mobilePct = stats.totalVisits > 0 ? Math.round((100 * stats.mobileVisits) / stats.totalVisits) : 0; + return ` + + + + +Browstack ${issueLabel} + + + +
    +
    +
    ${issueLabel}${fmtDate(issue.week_start)} — ${fmtDate(issue.week_end)}
    +
    Browstack
    +
    Your Personal Weekly Digest
    +
    +
    ${coverHtml}
    +
    + ${digestHtml} +

    本期選輯自你過去七天的 ${stats.footprintVisits.toLocaleString()} 次瀏覽足跡—— + ${articles.length} 篇深讀與 ${socialPosts.length} 則社群迴響,附編輯摘要。

    +
    +
    ${articles.length}本週深讀
    +
    ${socialPosts.length}社群迴響
    +
    ${stats.readingMinutes}內容分鐘
    +
    +
    + +
    +

    01 · 本週深讀

    + ${renderArticles(articles)} +
    + +
    +

    02 · 社群迴響

    + ${renderSocial(socialPosts)} +
    + +
    +

    03 · 一週圖譜

    +
    +
    瀏覽足跡 ${stats.footprintVisits.toLocaleString()}
    +
    手機佔比 ${mobilePct}%
    +
    內容頁造訪 ${stats.readingPages}
    +
    內容停留 ${stats.readingMinutes} 分鐘
    +
    +
    + +
    + BROWSTACK №${issue.number} · 由你的瀏覽紀錄自動編輯
    + 資料未離開這台機器 · PUBLISHED FOR AN AUDIENCE OF ONE +
    +
    + +`; +} diff --git a/src/render/preview.ts b/src/render/preview.ts index d7aee41..653d581 100644 --- a/src/render/preview.ts +++ b/src/render/preview.ts @@ -2,24 +2,29 @@ import fs from "node:fs"; import path from "node:path"; import { CONFIG } from "../config.js"; import { getDb } from "../db.js"; -import { findCover, getCurrentIssue, listIssues } from "../issue.js"; -import { selectIssueItems, type IssueItem } from "./select.js"; +import { findCover, getCurrentIssue, issueDigest, listIssues } from "../issue.js"; +import { renderIssueDocument, type IssueStats } from "./issueView.js"; +import { selectIssueItems } from "./select.js"; /** - * 週刊渲染器 v2:知識型內容 + 編輯摘要 + 主題分組 + 封面插畫。 - * 只收 is_knowledge=1 的內容——非知識型內容無論停留多久都不入刊。 + * 週刊網頁版渲染器:產出 out/browstack-issue-N.html 與典藏索引 out/index.html + * (供 file:// 直接開啟與 npm run preview;server 端另有即時渲染的 /archive)。 + * 單期版面與 archive 共用 issueView,確保外觀一致。 */ const CHROME_EPOCH_OFFSET_SEC = 11_644_473_600; const toChromeTime = (unixSec: number) => (unixSec + CHROME_EPOCH_OFFSET_SEC) * 1_000_000; +const fmtDate = (sec: number) => { + const d = new Date(sec * 1000); + return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; +}; const db = getDb(); const now = Math.floor(Date.now() / 1000); const weekAgo = now - 7 * 86400; +const weekChrome = toChromeTime(weekAgo); const { articles, socialPosts } = selectIssueItems(weekAgo); - -const weekChrome = toChromeTime(weekAgo); const footprint = db .prepare("SELECT COUNT(*) AS visits FROM visits_log WHERE visit_time > ?") .get(weekChrome) as { visits: number }; @@ -33,202 +38,37 @@ const reading = db const deviceSplit = db .prepare("SELECT v.device, COUNT(*) AS n FROM visits_log v WHERE v.visit_time > ? GROUP BY v.device") .all(weekChrome) as Array<{ device: string; n: number }>; - -const esc = (s: string) => - s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -const cleanTitle = (s: string) => esc(s.replace(/^\(\d+\)\s*/, "").replace(/\s*[||].*$/, "").trim()); -const deviceLabel = (d: string) => (d === "both" ? "桌機+手機" : d === "mobile" ? "手機" : "桌機"); -// 你當時讀了多久——這就是它被選進本期的原因 -const signalLabel = (i: IssueItem) => - i.active_min > 0 - ? `⚡ 本週你實讀了 ${i.active_min} 分鐘` - : `本週你停留了 ${i.minutes}${i.capped ? "+" : ""} 分鐘`; -const fmtDate = (sec: number) => { - const d = new Date(sec * 1000); - return `${d.getMonth() + 1} 月 ${d.getDate()} 日`; -}; - const mobileVisits = deviceSplit.find((d) => d.device === "mobile")?.n ?? 0; const totalVisits = deviceSplit.reduce((a, d) => a + d.n, 0); -// 主題分組:依組內最強訊號排序 -const topicGroups = new Map(); -for (const a of articles) { - const key = a.topic ?? "其他"; - if (!topicGroups.has(key)) topicGroups.set(key, []); - topicGroups.get(key)!.push(a); -} - -let rank = 0; -const articleHtml = [...topicGroups.entries()] - .map(([topic, items]) => { - const cards = items - .map((a) => { - rank++; - const s = JSON.parse(a.summary!) as { bullets?: string[]; takeaway?: string }; - const bullets = (s.bullets ?? []).map((b) => `
  • ${esc(b)}
  • `).join(""); - return ` -
    -
    ${String(rank).padStart(2, "0")}
    -
    - ${cleanTitle(a.title)} -
      ${bullets}
    - ${s.takeaway ? `
    ◈ ${esc(s.takeaway)}
    ` : ""} -
    ${new URL(a.url).hostname.replace(/^www\./, "")} · ${signalLabel(a)} · ${deviceLabel(a.devices)}
    -
    -
    `; - }) - .join("\n"); - return `

    ${esc(topic)}

    \n${cards}`; - }) - .join("\n"); - -const socialHtml = socialPosts - .map((p) => { - const s = JSON.parse(p.summary!) as { context?: string }; - const source = /threads\./.test(p.url) ? "Threads" : /facebook\./.test(p.url) ? "Facebook" : /linkedin\./.test(p.url) ? "LinkedIn" : "社群"; - return ` -
    - ${s.context ? `
    ${esc(s.context)}
    ` : ""} -
    ${esc(p.title.replace(/^\(\d+\)\s*/, "").trim())}
    -
    ${source} ${signalLabel(p)} · 查看原文
    -
    `; - }) - .join("\n"); - const issue = getCurrentIssue(); -const issueLabel = issue.title ? `№${issue.number} · ${issue.title}` : `№${issue.number}`; -// 封面:本期 png/jpg/svg → 最近一期封面 → 隨庫預設封面(渲染失敗不擋出刊) +// 封面:本期 png/jpg(base64 data-URI)/svg(內嵌)→ 最近一期 → 預設(渲染失敗不擋出刊) const coverPath = findCover(issue.number); -let coverSvg = ""; +let coverHtml = ""; if (coverPath?.endsWith(".png") || coverPath?.endsWith(".jpg")) { const mime = coverPath.endsWith(".jpg") ? "image/jpeg" : "image/png"; const b64 = fs.readFileSync(coverPath).toString("base64"); - coverSvg = `本期封面插畫`; + coverHtml = `本期封面插畫`; } else if (coverPath?.endsWith(".svg")) { - coverSvg = fs.readFileSync(coverPath, "utf8"); + coverHtml = fs.readFileSync(coverPath, "utf8"); } -const html = ` - - - - -Browstack ${issueLabel} - - - -
    -
    -
    ${issueLabel}${fmtDate(weekAgo)} — ${fmtDate(now)}
    -
    Browstack
    -
    Your Personal Weekly Digest
    -
    -
    ${coverSvg}
    -
    -

    本期選輯自你過去七天的 ${footprint.visits.toLocaleString()} 次瀏覽足跡—— - ${articles.length} 篇深讀與 ${socialPosts.length} 則社群迴響,附編輯摘要。

    -
    -
    ${articles.length}本週深讀
    -
    ${socialPosts.length}社群迴響
    -
    ${Math.round(reading.minutes ?? 0)}內容分鐘
    -
    -
    - -
    -

    01 · 本週深讀

    - ${articleHtml} -
    - -
    -

    02 · 社群迴響

    - ${socialHtml} -
    - -
    -

    03 · 一週圖譜

    -
    -
    瀏覽足跡 ${footprint.visits.toLocaleString()}
    -
    手機佔比 ${totalVisits > 0 ? Math.round((100 * mobileVisits) / totalVisits) : 0}%
    -
    內容頁造訪 ${reading.pages}
    -
    內容停留 ${Math.round(reading.minutes ?? 0)} 分鐘
    -
    -
    - -
    - BROWSTACK №${issue.number} · 由你的瀏覽紀錄自動編輯
    - 資料未離開這台機器 · PUBLISHED FOR AN AUDIENCE OF ONE -
    -
    - -`; +const stats: IssueStats = { + footprintVisits: footprint.visits, + mobileVisits, + totalVisits, + readingPages: reading.pages, + readingMinutes: Math.round(reading.minutes ?? 0), +}; +const html = renderIssueDocument({ issue, articles, socialPosts, stats, coverHtml, digest: issueDigest(issue.number) }); const outDir = path.join(CONFIG.dataDir, "..", "out"); fs.mkdirSync(outDir, { recursive: true }); const outPath = path.join(outDir, `browstack-issue-${issue.number}.html`); fs.writeFileSync(outPath, html); -// 典藏索引:out/index.html 列出歷來各期 +// 典藏索引:out/index.html 列出歷來各期(file:// 版;server 端另有即時 /archive) const archiveRows = listIssues() .map((i) => { const cover = findCover(i.number); @@ -257,5 +97,3 @@ const indexHtml = ` fs.writeFileSync(path.join(outDir, "index.html"), indexHtml); console.log(`已產出:${outPath}`); -console.log(`本期 ${issueLabel}:文章 ${articles.length} 篇(${topicGroups.size} 個主題)、社群貼文 ${socialPosts.length} 則`); -console.log(`典藏索引:${path.join(outDir, "index.html")}`); diff --git a/src/render/send.ts b/src/render/send.ts index 74c6cc9..8344862 100644 --- a/src/render/send.ts +++ b/src/render/send.ts @@ -2,8 +2,10 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import nodemailer from "nodemailer"; +import { ensureArchiveToken } from "../archiveToken.js"; import { CONFIG } from "../config.js"; import { findCover, getCurrentIssue, markIssueSent } from "../issue.js"; +import { SHARED } from "../shared/settings.js"; /** * 寄出本期週刊:Gmail SMTP +應用程式密碼(存 macOS Keychain,service: browstack-smtp)。 @@ -48,6 +50,15 @@ if (coverPath?.endsWith(".png") || coverPath?.endsWith(".jpg")) { ); } +// 典藏按鈕:token 在寄送當下才注入連結,且只改記憶體中的 html、不寫回磁碟——token 永不落地於 out/。 +// 連結只在同一台 Mac、接收服務運行時有效(手機開信為死連結,屬架構限制)。 +const archiveUrl = `http://127.0.0.1:${SHARED.serverPort}/archive?k=${ensureArchiveToken()}`; +const archiveButton = `
    + 在瀏覽器開啟你的典藏 → +
    在這台 Mac 上、Browstack 服務運行時開啟
    +
    `; +html = html.replace("", archiveButton); + const transporter = nodemailer.createTransport({ host: CONFIG.email.smtp.host, port: CONFIG.email.smtp.port, diff --git a/src/server.ts b/src/server.ts index 1980da7..1de0a9e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,136 @@ +import fs from "node:fs"; import http from "node:http"; +import path from "node:path"; +import { argv } from "node:process"; +import { fileURLToPath } from "node:url"; +import { checkArchiveKey, checkSessionCookie, getArchiveTokenCached, sessionCookieValue } from "./archiveToken.js"; import { classifyUrl } from "./classify/filter.js"; -import { getDb } from "./db.js"; +import { getDb, hardenPerms } from "./db.js"; +import { findCover } from "./issue.js"; +import { renderArchiveIndex, renderIssuePage } from "./render/archive.js"; import { SHARED } from "./shared/settings.js"; import { normalizeUrl } from "./shared/urls.js"; /** - * 本機接收服務:extension 唯一的通訊對象。 + * 本機接收服務:extension 的落地端,未來也端出典藏頁。 * 只綁 127.0.0.1——瀏覽資料永遠不出這台機器。 + * 開源前提:攻擊者完全知道本檔內容,安全只押在每台各異的隨機 token,不押在保密機制。 */ +// 綁定位址永遠是本機迴環,絕不可改成對外可達位址或任何可設定值(會把歷史 server 曝露到區網)。 +export const BIND_ADDRESS = "127.0.0.1"; + +// 只接受本機 Host(精確比對)——擋 DNS rebinding:rebinding 攻擊頁送的 Host 是攻擊者網域,永不在此集合。 +const ALLOWED_HOSTS = new Set([`127.0.0.1:${SHARED.serverPort}`, `localhost:${SHARED.serverPort}`]); + +// HTML/圖片回應共用的嚴格安全標頭(單一 choke point;新路由一律經過它,不逐路由手寫)。 +// 刻意不設腳本來源指令——default-src 'none' 已封殺所有腳本;img-src 需含 data: 否則內嵌 base64 封面全空白。 +export const SECURITY_HEADERS: Readonly> = { + "content-security-policy": + "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; " + + "frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + "x-content-type-options": "nosniff", + "cross-origin-resource-policy": "same-origin", + "referrer-policy": "no-referrer", + "cache-control": "no-store", +}; + +// Host 正規化後精確比對凍結集合。缺失/不符一律 false。 +// 只用 slice/charAt/Set.has,不用 includes/startsWith/endsWith/RegExp 做比對決策(那些容易被放寬成 rebinding 破口)。 +function hostAllowed(rawHost: string | undefined): boolean { + if (!rawHost) return false; + const host = rawHost.toLowerCase(); + const colon = host.lastIndexOf(":"); + const name = colon >= 0 ? host.slice(0, colon) : host; + const port = colon >= 0 ? host.slice(colon) : ""; + // 去掉主機名尾端單一個「.」("localhost.:8787" / "127.0.0.1.:8787" 仍指向本機) + const cleanName = name.charAt(name.length - 1) === "." ? name.slice(0, -1) : name; + return ALLOWED_HOSTS.has(cleanName + port); +} + +// 典藏頁的 session cookie。值是 token 的 sha256(見 archiveToken.ts),非 token 本身。 +const COOKIE_NAME = "bs"; +const COOKIE_MAX_AGE = 7 * 24 * 3600; // 7 天 +const IMAGE_TYPES: Readonly> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", +}; + +// 從 Cookie 標頭取出指定 cookie(用 indexOf/slice,不用 includes/startsWith——見 Host 檢查的同理)。 +function parseCookie(header: string | undefined, name: string): string | null { + if (!header) return null; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return null; +} + +function sendHtml(res: http.ServerResponse, code: number, html: string): void { + if (res.headersSent) { + res.destroy(); + return; + } + res.writeHead(code, { ...SECURITY_HEADERS, "content-type": "text/html; charset=utf-8" }); + res.end(html); +} + +// 只端已知副檔名的圖片,且套同一組安全標頭(SVG 也被 CSP 中和,縱使 cover.ts 已於生成時淨化)。 +function sendCover(res: http.ServerResponse, filePath: string): void { + if (res.headersSent) { + res.destroy(); + return; + } + const type = IMAGE_TYPES[path.extname(filePath).toLowerCase()]; + if (!type) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false })); + return; + } + let buf: Buffer; + try { + buf = fs.readFileSync(filePath); + } catch { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: false })); + return; + } + res.writeHead(200, { ...SECURITY_HEADERS, "content-type": type }); + res.end(buf); +} + +// 驗 ?k 通過 → 發 cookie 並 302 到乾淨路徑(Location 用 server 端算出的 pathname,絕不回填請求字串)。 +function sendRedirect(res: http.ServerResponse, location: string, cookieValue: string): void { + if (res.headersSent) { + res.destroy(); + return; + } + // 一併帶上安全標頭(含 referrer-policy: no-referrer)——這個回應的 URL 帶著 ?k=token, + // 不能成為任何 Referer 來源。 + res.writeHead(302, { + ...SECURITY_HEADERS, + location, + "set-cookie": `${COOKIE_NAME}=${cookieValue}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`, + }); + res.end(); +} + +type AuthResult = "serve" | "deny" | { redirectTo: string; cookieValue: string }; + +// 認證:合法 ?k → 發 cookie + 302(SameSite=Lax 確保 Gmail 跨站點擊後的頂層導航仍帶 cookie); +// 否則看 cookie;都沒有 → deny。stored 缺失一律 fail closed。 +function authorize(req: http.IncomingMessage, url: URL, stored: string | null): AuthResult { + const k = url.searchParams.get("k"); + if (k && checkArchiveKey(k, stored)) { + return { redirectTo: url.pathname, cookieValue: sessionCookieValue(stored as string) }; + } + if (checkSessionCookie(parseCookie(req.headers.cookie, COOKIE_NAME), stored)) return "serve"; + return "deny"; +} + interface CaptureItem { event: "capture" | "final"; captureId: string; @@ -113,29 +235,84 @@ function readBody(req: http.IncomingMessage, limit: number): Promise { }); } -const server = http.createServer(async (req, res) => { - const send = (code: number, body: unknown) => { - res.writeHead(code, { "content-type": "application/json" }); - res.end(JSON.stringify(body)); - }; - try { - if (req.method === "GET" && req.url === "/health") { - return send(200, { ok: true, service: "browstack" }); - } - if (req.method === "POST" && req.url === "/capture") { - const raw = await readBody(req, 10 * 1024 * 1024); - const parsed = JSON.parse(raw) as { items?: CaptureItem[] }; - if (!Array.isArray(parsed.items)) return send(400, { ok: false, error: "items required" }); - const result = handleBatch(parsed.items); - console.log(`[capture] 收到 ${parsed.items.length} 筆:落地 ${result.accepted}、略過 ${result.skipped}`); - return send(200, { ok: true, ...result }); +// getToken 可注入(測試用固定 token,免動 Keychain);預設從 Keychain 讀。 +export function createBrowstackServer(opts: { getToken?: () => string | null } = {}): http.Server { + // 預設用短 TTL 快取版:避免每個請求(含索引頁每張封面)都 fork `security` 阻塞事件迴圈。 + const getToken = opts.getToken ?? getArchiveTokenCached; + return http.createServer(async (req, res) => { + // headersSent 防護:串流/已回應的請求不得再寫一次(避免 crash-loop)。 + const sendJson = (code: number, body: unknown) => { + if (res.headersSent) { + res.destroy(); + return; + } + res.writeHead(code, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + try { + // Host 閘:所有路由(含 /capture)都先過,再談路由。 + if (!hostAllowed(req.headers.host)) return sendJson(403, { ok: false }); + + // req.url 是「路徑+query」,且可能是 absolute-form;統一以 URL 解析,只取 pathname 做路由。 + const url = new URL(req.url ?? "/", `http://127.0.0.1:${SHARED.serverPort}`); + const pathname = url.pathname; + const method = req.method === "HEAD" ? "GET" : req.method; + + if (method === "GET" && pathname === "/health") { + // 去產品指紋:不回傳 service 名(否則任何網站可探測「此訪客在用 Browstack」)。 + return sendJson(200, { ok: true }); + } + + // 典藏路由(唯讀 GET,需 token 或 cookie)。整數路由 server 端組路徑,無 client 可控檔名。 + if (method === "GET") { + const isIndex = pathname === "/" || pathname === "/archive"; + const issuesMatch = /^\/issues\/(0|[1-9]\d{0,5})$/.exec(pathname); + const coversMatch = /^\/covers\/(0|[1-9]\d{0,5})$/.exec(pathname); + if (isIndex || issuesMatch || coversMatch) { + const auth = authorize(req, url, getToken()); + if (auth === "deny") return sendJson(403, { ok: false }); + if (typeof auth === "object") return sendRedirect(res, auth.redirectTo, auth.cookieValue); + if (isIndex) return sendHtml(res, 200, renderArchiveIndex()); + if (issuesMatch) { + const page = renderIssuePage(Number(issuesMatch[1])); + return page ? sendHtml(res, 200, page) : sendJson(404, { ok: false }); + } + const cover = findCover(Number(coversMatch![1]), { exactOnly: true }); + return cover ? sendCover(res, cover) : sendJson(404, { ok: false }); + } + } + + if (req.method === "POST" && pathname === "/capture") { + // 要求 application/json:逼跨站寫入走 CORS preflight(本 server 不回 CORS 標頭 → 被擋), + // 封掉任何網頁用 no-cors text/plain 灌假資料進本機 DB 的路。extension 本就送 application/json。 + const ctype = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase(); + if (ctype !== "application/json") { + return sendJson(415, { ok: false, error: "content-type must be application/json" }); + } + const raw = await readBody(req, 10 * 1024 * 1024); + const parsed = JSON.parse(raw) as { items?: CaptureItem[] }; + if (!Array.isArray(parsed.items)) return sendJson(400, { ok: false, error: "items required" }); + const result = handleBatch(parsed.items); + console.log(`[capture] 收到 ${parsed.items.length} 筆:落地 ${result.accepted}、略過 ${result.skipped}`); + return sendJson(200, { ok: true, ...result }); + } + sendJson(404, { ok: false }); + } catch (e) { + // 只記 error code,絕不把請求輸入(可能含 token 等敏感值)回填進回應或日誌。 + const code = (e as NodeJS.ErrnoException)?.code; + if (code) console.error(`[server] 請求處理失敗:${code}`); + sendJson(400, { ok: false }); } - send(404, { ok: false }); - } catch (e) { - send(400, { ok: false, error: String(e) }); - } -}); + }); +} -server.listen(SHARED.serverPort, "127.0.0.1", () => { - console.log(`browstack 本機接收服務:http://127.0.0.1:${SHARED.serverPort}(只綁本機,資料不出機器)`); -}); +// 作為主程式(tsx src/server.ts)執行時才實際 listen;被測試 import 時只拿 handler,不佔 port。 +// 兩邊都過 realpath:argv[1] 可能帶符號連結(如 macOS /tmp→/private/tmp),而 import.meta.url 已是實路徑。 +const isMain = + !!argv[1] && fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(argv[1]); +if (isMain) { + hardenPerms(); // 開機即收緊既有資料檔權限(不必等第一次 DB 存取) + createBrowstackServer().listen(SHARED.serverPort, BIND_ADDRESS, () => { + console.log(`browstack 本機接收服務:http://${BIND_ADDRESS}:${SHARED.serverPort}(只綁本機,資料不出機器)`); + }); +} diff --git a/src/shared/html.ts b/src/shared/html.ts new file mode 100644 index 0000000..222de4f --- /dev/null +++ b/src/shared/html.ts @@ -0,0 +1,28 @@ +/** + * HTML 輸出跳脫——共用於 email 與 preview/archive 渲染。 + * 內容多來自用戶瀏覽過的任意網頁(標題、摘要、URL),視為攻擊者可影響的字串。 + * 純字串運算、無 Node 相依。 + */ + +// 同時處理文字與屬性語境:& < > 之外,還跳脫 " 與 ', +// 讓 href="${esc(...)}" 這類屬性插值也不會被引號突破。 +export function esc(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +// href 只允許 http/https;其餘(javascript:、data: 等)一律歸零成 "#"。 +// 回傳值已經跳脫,可直接放進 href="${safeHref(url)}"。 +export function safeHref(url: string): string { + try { + const u = new URL(url); + if (u.protocol === "http:" || u.protocol === "https:") return esc(url); + } catch { + // 無法解析的字串不當作連結 + } + return "#"; +} diff --git a/src/tools/archiveOpen.ts b/src/tools/archiveOpen.ts new file mode 100644 index 0000000..49acf6b --- /dev/null +++ b/src/tools/archiveOpen.ts @@ -0,0 +1,14 @@ +import { execFileSync } from "node:child_process"; +import { ensureArchiveToken } from "../archiveToken.js"; +import { SHARED } from "../shared/settings.js"; + +// 不透過信件、直接開啟本機典藏。用 open(1) 帶 token 開瀏覽器,token 不落在 shell history。 +const url = `http://127.0.0.1:${SHARED.serverPort}/archive?k=${ensureArchiveToken()}`; +try { + execFileSync("open", [url], { stdio: "ignore" }); + console.log("已在瀏覽器開啟你的典藏。"); +} catch { + // 不印出帶 token 的網址(避免落在終端機/history) + console.error("無法自動開啟瀏覽器。請確認接收服務運行中(npm run serve 或已排程),再重試 npm run archive:open。"); + process.exit(1); +} diff --git a/src/tools/rotateToken.ts b/src/tools/rotateToken.ts new file mode 100644 index 0000000..e175569 --- /dev/null +++ b/src/tools/rotateToken.ts @@ -0,0 +1,7 @@ +import { rotateArchiveToken } from "../archiveToken.js"; + +// 更新典藏 token:舊信件裡的按鈕即刻失效,下一期出刊會帶新連結。 +rotateArchiveToken(); +console.log("已更新典藏 token(存入 Keychain: browstack-archive)。"); +console.log("舊信件的典藏按鈕即刻失效;下一期出刊會帶新連結。"); +console.log("要現在就開啟典藏,執行: npm run archive:open"); diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 0000000..f33463f --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { after, before, test } from "node:test"; +import { sessionCookieValue } from "../src/archiveToken.js"; +import { BIND_ADDRESS, SECURITY_HEADERS, createBrowstackServer } from "../src/server.js"; + +/** + * 安全不變式:對照 SECURITY.md。這些斷言鎖住「看似無害的 PR 會悄悄破壞」的性質。 + * 認證用注入的固定 token(免動 Keychain);deny/redirect/cover 路徑不觸發 DB 渲染, + * 所以整個測試不需 better-sqlite3、不開啟真實 issues 資料。 + */ + +const TEST_TOKEN = "a".repeat(64); // 合法格式的假 token(/^[0-9a-f]{64}$/) +const GOOD_COOKIE = `${"bs"}=${sessionCookieValue(TEST_TOKEN)}`; + +let server: http.Server; +let port: number; + +before(async () => { + server = createBrowstackServer({ getToken: () => TEST_TOKEN }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + port = (server.address() as AddressInfo).port; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))); +}); + +interface Res { + status: number; + body: string; + headers: http.IncomingHttpHeaders; +} + +// host: undefined → 送合法 Host;null → 送空 Host;字串 → 送該值。TCP 一律連本機臨時 port, +// 但 Host header 可獨立指定,正好用來測 Host 閘。 +function request(opts: { + method?: string; + path?: string; + host?: string | null; + contentType?: string; + cookie?: string; + body?: string; +}): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + headers.host = opts.host === undefined ? "127.0.0.1:8787" : (opts.host ?? ""); + if (opts.contentType) headers["content-type"] = opts.contentType; + if (opts.cookie) headers.cookie = opts.cookie; + if (opts.body !== undefined) headers["content-length"] = String(Buffer.byteLength(opts.body)); + const req = http.request( + { host: "127.0.0.1", port, method: opts.method ?? "GET", path: opts.path ?? "/", headers }, + (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data, headers: res.headers })); + }, + ); + req.on("error", reject); + if (opts.body !== undefined) req.write(opts.body); + req.end(); + }); +} + +test("Host 閘:合法本機 Host 放行", async () => { + assert.equal((await request({ path: "/health", host: "127.0.0.1:8787" })).status, 200); + assert.equal((await request({ path: "/health", host: "localhost:8787" })).status, 200); +}); + +test("Host 閘:主機名尾端單一點仍視為本機", async () => { + assert.equal((await request({ path: "/health", host: "127.0.0.1.:8787" })).status, 200); + assert.equal((await request({ path: "/health", host: "localhost.:8787" })).status, 200); +}); + +test("Host 閘:非本機 Host 一律 403(擋 DNS rebinding)", async () => { + for (const host of ["evil.com", "127.0.0.1:8787.evil.com", "127.0.0.1:9999", "attacker:8787"]) { + assert.equal((await request({ path: "/health", host })).status, 403, `host=${host} 應 403`); + } +}); + +test("Host 閘:缺失/空 Host → 403", async () => { + assert.equal((await request({ path: "/health", host: null })).status, 403); +}); + +test("/health 去指紋:回 {ok:true},不洩漏產品名", async () => { + const res = await request({ path: "/health" }); + assert.equal(res.status, 200); + assert.deepEqual(JSON.parse(res.body), { ok: true }); + assert.ok(!/browstack|service/i.test(res.body), "/health 不應含產品名或 service 欄位"); +}); + +test("/capture:非 application/json 一律拒絕", async () => { + const textPlain = await request({ + method: "POST", + path: "/capture", + contentType: "text/plain", + body: JSON.stringify({ items: [] }), + }); + assert.equal(textPlain.status, 415, "text/plain(no-cors 汙染路徑)應被擋"); + const noType = await request({ method: "POST", path: "/capture", body: "{}" }); + assert.equal(noType.status, 415, "無 content-type 應被擋"); +}); + +test("/capture:application/json 通過 content-type 閘({} → 400 items required,未觸發 DB)", async () => { + const res = await request({ + method: "POST", + path: "/capture", + contentType: "application/json", + body: "{}", + }); + assert.equal(res.status, 400); + assert.match(res.body, /items/); +}); + +test("/capture:Host 閘先於 content-type(非本機 Host 直接 403)", async () => { + const res = await request({ + method: "POST", + path: "/capture", + host: "evil.com", + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }); + assert.equal(res.status, 403); +}); + +test("未知路由 → 404", async () => { + assert.equal((await request({ path: "/does-not-exist" })).status, 404); +}); + +test("CSP:default-src 'none'、含 img data:、無 script-src、無 unsafe-eval", () => { + const csp = SECURITY_HEADERS["content-security-policy"]; + assert.match(csp, /default-src 'none'/); + assert.match(csp, /img-src 'self' data:/); + assert.match(csp, /frame-ancestors 'none'/); + assert.doesNotMatch(csp, /script-src/, "不應有 script-src(default-src 'none' 已封殺腳本)"); + assert.doesNotMatch(csp, /unsafe-eval/); +}); + +test("綁定位址永遠是本機迴環", () => { + assert.equal(BIND_ADDRESS, "127.0.0.1"); +}); + +test("典藏路由:無 token 無 cookie → 403", async () => { + assert.equal((await request({ path: "/archive" })).status, 403); + assert.equal((await request({ path: "/issues/3" })).status, 403); + assert.equal((await request({ path: "/covers/3" })).status, 403); +}); + +test("典藏路由:錯誤 k → 403", async () => { + assert.equal((await request({ path: "/archive?k=" + "b".repeat(64) })).status, 403); + assert.equal((await request({ path: "/archive?k=notavalidtoken" })).status, 403); +}); + +test("典藏路由:合法 k → 302 + 設定 HttpOnly SameSite=Lax cookie,導向乾淨路徑", async () => { + const res = await request({ path: "/archive?k=" + TEST_TOKEN }); + assert.equal(res.status, 302); + assert.equal(res.headers.location, "/archive"); + const setCookie = ([] as string[]).concat(res.headers["set-cookie"] ?? []).join(" "); + assert.match(setCookie, /bs=/); + assert.match(setCookie, /HttpOnly/); + assert.match(setCookie, /SameSite=Lax/); + assert.doesNotMatch(setCookie, new RegExp(TEST_TOKEN), "cookie 不得是 token 本身"); +}); + +test("典藏路由:/issues/N 帶合法 k 也會換發 cookie 並 302 回乾淨路徑", async () => { + const res = await request({ path: "/issues/5?k=" + TEST_TOKEN }); + assert.equal(res.status, 302); + assert.equal(res.headers.location, "/issues/5"); +}); + +test("整數路由:前導零/非數字/路徑穿越一律不匹配 → 404(有 cookie 也不放行檔案)", async () => { + for (const p of ["/issues/01", "/issues/1.5", "/issues/-1", "/issues/abc", "/issues/..%2f..%2fetc%2fpasswd", "/covers/..%2f..%2fdata%2fbrowstack.db"]) { + assert.equal((await request({ path: p, cookie: GOOD_COOKIE })).status, 404, `${p} 應 404`); + } +}); + +test("/covers/N:有 cookie → 200 圖片,且帶 CSP/nosniff/CORP 安全標頭", async () => { + const res = await request({ path: "/covers/3", cookie: GOOD_COOKIE }); + assert.equal(res.status, 200); + assert.match(String(res.headers["content-type"]), /^image\//); + assert.match(String(res.headers["content-security-policy"]), /default-src 'none'/); + assert.equal(res.headers["x-content-type-options"], "nosniff"); + assert.equal(res.headers["cross-origin-resource-policy"], "same-origin"); +}); + +test("典藏路由仍先過 Host 閘(非本機 Host + 合法 cookie → 403)", async () => { + const res = await request({ path: "/archive", host: "evil.com", cookie: GOOD_COOKIE }); + assert.equal(res.status, 403); +});