diff --git a/docs/server-refactor-plan.md b/docs/server-refactor-plan.md index c012372f..c72a94e5 100644 --- a/docs/server-refactor-plan.md +++ b/docs/server-refactor-plan.md @@ -13,52 +13,58 @@ | 項目 | 内容 | |------|------| | ファイル | `/server.js`(リポジトリルート) | -| 行数 | **3091行**(2026-04-10 計測) | +| 行数 | **約3130行**(2026-04-10 計測・セキュリティ修正追加後) | | 役割 | VPS 上で動く Node.js/Express バックエンド。全アプリの API を1ファイルに集約 | | 問題 | テストが書けない・障害切り分けが困難・新機能追加のたびにファイルが肥大化 | -| 状態 | **現時点では本番稼働中・障害なし。即時分割は不要。** | +| 状態 | **本番稼働中・障害なし。2026-04-10 にセキュリティ修正7件適用済み。** | +| セキュリティ | SSRF ガード追加・レスポンスサイズ上限追加・DB pool 設定改善済み | --- ## 1. 現在の内部構造(セクション別行数) ``` -server.js (3091行) +server.js (約3130行) ├── L1–27 require / app 初期化 ├── L28–84 Auth helpers(WebAuthn dynamic import、インメモリレートリミッター) ├── L85–126 JWT config + session helpers ├── L127–173 Express middleware(CORS など) -├── L174–260 DB pool (pg) + Gemini + API Key 認証マップ -├── L261–481 共有ユーティリティ(extractSource、charset 正規化、SSRF guard、 -│ fetchMeta、fetchFullTextViaJina、analyzeWithGemini) -├── L482–721 initDB(PostgreSQL スキーマ全定義 — 240行) -├── L722–769 Express Router 生成 + /health +├── L174–300 DB pool (pg) + Gemini + API Key 認証マップ +│ ※ pool.max 15・タイムアウト設定・pool.on('error') 追加済み +├── L261–520 共有ユーティリティ(extractSource、charset 正規化、 +│ isSsrfSafe(NEW)、fetchMeta(SSRF+2MB上限追加)、 +│ fetchFullTextViaJina(SSRF+1MB上限追加)、analyzeWithGemini) +├── L521–760 initDB(PostgreSQL スキーマ全定義 — 240行) +├── L761–809 Express Router 生成 + /health │ ├── ── ルートグループ ───────────────────────────────────────────── -├── L770–1106 Auth: Magic Link + session(337行) -├── L926–1106 Auth: Google OAuth / GitHub OAuth(181行)※上記と連続 -├── L1107–1354 Auth: Passkey / WebAuthn(248行) +├── L810–1146 Auth: Magic Link + session(337行) +├── L966–1146 Auth: Google OAuth / GitHub OAuth(181行)※上記と連続 +├── L1147–1394 Auth: Passkey / WebAuthn(248行) │ 合計 Auth routes: 585行 │ -├── L1355–1612 Brain articles(GET/POST/PATCH/DELETE + save + history = 258行) -├── L1613–1685 Journal routes(posts + public + AI tag suggestion + upload = 170行) -├── L1686–1782 Site Config routes(96行) -├── L1783–1884 Habit routes(102行) -├── L1885–1930 Pulse routes(46行) -├── L1931–1967 Lens history routes(37行) +├── L1395–1680 Brain articles(GET/POST/PATCH/DELETE + save(即時保存+非同期AI)+ history) +│ ※ POST /save: 即時INSERT → setImmediate() で fetchMeta/AI をバックグラウンド実行 +├── L1681–1760 Journal routes(posts + public + AI tag suggestion + upload) +├── L1761–1860 Site Config routes +├── L1861–1960 Habit routes +├── L1961–2010 Pulse routes +├── L2011–2050 Lens history routes │ -├── L1968–2118 Feed Media + Feed Articles routes(151行) -├── L2119–2288 TTS(VOICEVOX)routes + サーバー側自動プリウォーム(170行) +├── L2051–2210 Feed Media + Feed Articles routes +├── L2211–2390 TTS(VOICEVOX)routes + サーバー側自動プリウォーム │ -├── L2289–2320 Events前処理(32行) -├── L2321–2775 Together routes(groups / join / share / feed / comments / search = 455行) -├── L2625–2775 Atlas proxy(GitHub / Vercel / Tailscale scan = 151行)※上記と連続 +├── L2391–2430 Events前処理 +├── L2431–2900 Together routes(groups / join / share / feed / comments / search) +│ ※ POST /together/share: isSsrfSafe() 適用済み +│ ※ archiveShare(): isSsrfSafe() + 1MB上限適用済み +├── L2740–2900 Atlas proxy(GitHub / Vercel / Tailscale scan)※上記と連続 │ -├── L2776–2884 Doorkeeper + connpass(app-level、router 外 = 109行) -├── L2885–2889 静的ファイル(/uploads) -├── L2891–2989 Stripe webhook(99行) +├── L2901–3010 Doorkeeper + connpass(app-level、router 外) +├── L3011–3015 静的ファイル(/uploads) +├── L3017–3115 Stripe webhook │ -└── L2990–3091 router マウント + サーバー起動 + Feed 背景ジョブ(102行) +└── L3116–3130 router マウント + サーバー起動 + Feed 背景ジョブ ``` --- @@ -74,10 +80,10 @@ server.js (3091行) | `genAI` (Gemini) | L191 | 2箇所 | brain.js のみ | | `webauthnChallenges` (Map) | L40 | 15箇所 | auth routes のみ | | `rateLimitStores` / `rateLimit()` | L51 | 多数 | auth routes 中心 | -| `isSsrfSafe()` | L294 | 5箇所 | brain.js / together.js | -| `fetchMeta()` | L304 | brain + together | -| `fetchFullTextViaJina()` | L372 | brain + together | -| `analyzeWithGemini()` | L416 | brain のみ | +| `isSsrfSafe()` | L294付近 | 7箇所 | brain.js / together.js | +| `fetchMeta()` | L304付近 | brain + together | +| `fetchFullTextViaJina()` | L372付近 | brain + together | +| `analyzeWithGemini()` | L416付近 | brain のみ | | `JWT_SECRET` | L86 | auth 全体 | | `WEBAUTHN_RP_*` | L92 | auth/passkey のみ | @@ -156,7 +162,7 @@ const pool = new Pool({ }); pool.on('error', (err) => console.error('[DB] Unexpected pool error:', err.message)); -async function initDB() { /* server.js L482–721 をそのまま移動 */ } +async function initDB() { /* server.js の initDB をそのまま移動 */ } module.exports = { pool, initDB }; ``` @@ -176,6 +182,7 @@ module.exports = { rateLimit, webauthnChallenges }; **lib/fetch.js** ```js // isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini, extractSource +// ※ isSsrfSafe は 2026-04-10 に server.js 内で実装済み。コピーして移動するだけ。 module.exports = { isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini }; ``` @@ -233,15 +240,74 @@ app.use('/api/stripe', stripeRouter); ### フェーズ 2 の注意点(ハマりやすい箇所) -1. **feed バックグラウンドジョブ**(L2998–3091)は routes/feed.js に移すが、`startFeedJob()` の呼び出しは server.js の起動後に行う必要がある。`module.exports = { router, startFeedJob }` の形式にする。 +1. **feed バックグラウンドジョブ**は routes/feed.js に移すが、`startFeedJob()` の呼び出しは server.js の起動後に行う必要がある。`module.exports = { router, startFeedJob }` の形式にする。 -2. **Doorkeeper / connpass の二重定義**(L2776–2884 が router 外の app-level)は events.js に統合し、router 内に移動する。`/brain/api/events/*` と `/api/events/*` の両マウントを忘れずに。 +2. **Doorkeeper / connpass の二重定義**(router 外の app-level)は events.js に統合し、router 内に移動する。`/brain/api/events/*` と `/api/events/*` の両マウントを忘れずに。 3. **auth routes の webauthnChallenges** は auth.js ルートファイルと lib/rateLimit.js(またはlib/auth.js)が共有するため、フェーズ1の lib 切り出し完了後でないと移動できない。必ずフェーズ1の後に行う。 +4. **POST /save の setImmediate() バックグラウンド処理**(2026-04-10 追加): brain.js に移動する際、`setImmediate()` コールバック内の `pool.query` は routes/brain.js スコープで `pool` にアクセスできる必要がある。lib/db.js から require する形にすれば問題ない。 + --- -## 6. やらないこと(スコープ外) +## 6. 2026-04-10 適用済みセキュリティ・信頼性修正 + +リファクタリング前に既に対処済みの問題。分割作業時はこの修正が維持されていることを確認すること。 + +| # | 修正内容 | 箇所 | リスク(修正前) | +|---|---------|------|----------------| +| 1 | **DB pool max: 5 → 15** + `idleTimeoutMillis: 30000` + `connectionTimeoutMillis: 5000` 追加 | `pool` 初期化 | setImmediate() 並列実行時に pool 枯渇 | +| 2 | **`pool.on('error', ...)` ハンドラ追加** | `pool` 初期化直後 | 未捕捉エラーでプロセスクラッシュ | +| 3 | **`isSsrfSafe()` 関数追加** | 共有ユーティリティ冒頭 | ユーザー入力 URL を VPS から fetch → イントラネット攻撃 | +| 4 | **`fetchMeta()` に SSRF ガード + 2MB上限** | fetchMeta 関数 | 内部 IP への GET・無制限レスポンスでメモリ枯渇 | +| 5 | **`fetchFullTextViaJina()` に SSRF ガード + 1MB上限** | fetchFullTextViaJina 関数 | Jina 経由の SSRF・巨大レスポンスでメモリ枯渇 | +| 6 | **`POST /together/share` に `isSsrfSafe()` 適用** | Together routes | Together 投稿 URL 経由の SSRF | +| 7 | **`archiveShare()` に `isSsrfSafe()` + 1MB上限** | archiveShare 関数 | アーカイブ処理での SSRF・サイズ無制限 | + +### isSsrfSafe() の実装(lib/fetch.js に移動予定) + +```js +const SSRF_BLOCKED = /^(127\.|localhost$|::1$|0\.0\.0\.0$|169\.254\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.100\.100\.100|metadata\.google\.internal)/i; + +function isSsrfSafe(rawUrl) { + let parsed; + try { parsed = new URL(rawUrl); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + if (SSRF_BLOCKED.test(parsed.hostname)) return false; + return true; +} +``` + +--- + +## 7. POST /save の非同期処理パターン(2026-04-10 実装) + +記事保存の即時性改善のため、POST /save と GET /quick-save を以下のパターンに変更済み。 +分割時にこのパターンを維持すること。 + +```js +// 1. 即時 INSERT(domain を仮タイトルとして使用) +await pool.query(`INSERT INTO articles (...) VALUES (...)`, [userId, url, domain, null, null, ...]); +res.json({ ok: true, article: { id, title: domain, ... } }); + +// 2. バックグラウンドで fetchMeta → DB UPDATE → Jina → DB UPDATE → Gemini → DB UPDATE +setImmediate(async () => { + try { + const meta = await fetchMeta(url); + await pool.query(`UPDATE articles SET title=$1, favicon=$2 WHERE id=$3`, [meta.title, meta.favicon, articleId]); + const fullText = await fetchFullTextViaJina(url); + await pool.query(`UPDATE articles SET full_text=$1 WHERE id=$2`, [fullText, articleId]); + const summary = await analyzeWithGemini(fullText || meta.description || ''); + await pool.query(`UPDATE articles SET summary=$1 WHERE id=$2`, [summary, articleId]); + } catch (e) { + console.error('[save/background]', e.message); + } +}); +``` + +--- + +## 8. やらないこと(スコープ外) | 項目 | 理由 | |------|------| @@ -249,18 +315,50 @@ app.use('/api/stripe', stripeRouter); | テストコード追加 | 分割と同時にやると範囲が広がりすぎる。分割後の別タスク | | DB_USER: 'gitea' の修正 | サーバー側 PostgreSQL ユーザー変更が伴う。別タスク | | console.log 整理 | 分割と無関係。別タスク | -| インメモリ state の永続化 | スキーマ変更が必要。別タスク(CLAUDE.md 要確認事項) | +| インメモリ state の永続化(Redis 等) | `webauthnChallenges` / `rateLimitStores` をDB/Redisに移行。スキーマ変更が必要・**mai 確認必須** | --- -## 7. 検討中の追加改善(分割後の次フェーズ候補) +## 9. 検討中の追加改善(分割後の次フェーズ候補) - **DB_USER を 'gitea' から 'posimai' に変更**(PostgreSQL ユーザー作成が必要) - **webauthnChallenges / rateLimitStores の Redis 移行**(再起動耐性・スケール対応) - **console.log を構造化ログ(pino 等)に統一** - **ルートごとのエラーハンドリング統一**(現状は各ルートで個別 try/catch) +- **`isSsrfSafe()` の DNS 解決後チェック追加**(DNS rebinding 対策。現状はホスト名の正規表現のみ) + +--- + +## 10. 次ステップ(優先順) + +商用化とリファクタリングは並列トラックで管理する。 + +### トラック A: 商用化(最優先) + +| ステップ | 担当 | 前提 | +|---------|------|------| +| どのアプリに premium 機能を実装するか決める | **mai 決定必要** | — | +| 特商法ページ記入(事業者名・住所・電話番号) | mai 手動作業 | — | +| Store デザイン確定(A/B/C/D) | Eiji + mai | — | +| 各アプリへの `purchased` フラグ実装 | AI 対応可 | mai の対象決定後 | +| Stripe 本番モード切り替え | AI + mai 確認 | Store 確定・特商法完了後 | + +### トラック B: リファクタリング(タイミング待ち) + +セクション 4 の「開始するべきタイミング」に該当するまで待つ。 +現時点で最も近いトリガーは「server.js が 3500行を超えたとき」(現在 約3130行)。 + +新機能追加(posimai-station API 等)が発生した場合は、そのタイミングでフェーズ1から着手する。 --- *このドキュメントは作業開始時・完了時に更新すること。* *分割を実施した AI は「フェーズ X 完了」「完了日」「担当 AI」を末尾に追記してください。* + +--- + +## 完了履歴 + +| フェーズ | 完了日 | 担当 AI | 内容 | +|---------|-------|--------|------| +| セキュリティ修正(フェーズ0相当) | 2026-04-10 | Claude Sonnet 4.6 | SSRF ガード・サイズ上限・pool 設定改善・即時保存パターン(全7件) |