docs: merge security fixes and next steps into server-refactor-plan

- Add section 6: 7 security/reliability fixes applied 2026-04-10
  (SSRF guard, size limits, pool config, error handler)
- Add section 7: POST /save async pattern documentation
- Add section 10: prioritized next steps (commercialization + refactor tracks)
- Add completion history table
- Update line number estimates to reflect additions
- Update current line count to ~3130

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-10 14:00:15 +09:00
parent 8fdc047b7f
commit 5b17d9215c
1 changed files with 134 additions and 36 deletions

View File

@ -13,52 +13,58 @@
| 項目 | 内容 | | 項目 | 内容 |
|------|------| |------|------|
| ファイル | `/server.js`(リポジトリルート) | | ファイル | `/server.js`(リポジトリルート) |
| 行数 | **3091行**2026-04-10 計測) | | 行数 | **約3130行**2026-04-10 計測・セキュリティ修正追加後 |
| 役割 | VPS 上で動く Node.js/Express バックエンド。全アプリの API を1ファイルに集約 | | 役割 | VPS 上で動く Node.js/Express バックエンド。全アプリの API を1ファイルに集約 |
| 問題 | テストが書けない・障害切り分けが困難・新機能追加のたびにファイルが肥大化 | | 問題 | テストが書けない・障害切り分けが困難・新機能追加のたびにファイルが肥大化 |
| 状態 | **現時点では本番稼働中・障害なし。即時分割は不要。** | | 状態 | **本番稼働中・障害なし。2026-04-10 にセキュリティ修正7件適用済み。** |
| セキュリティ | SSRF ガード追加・レスポンスサイズ上限追加・DB pool 設定改善済み |
--- ---
## 1. 現在の内部構造(セクション別行数) ## 1. 現在の内部構造(セクション別行数)
``` ```
server.js (3091行) server.js (約3130行)
├── L127 require / app 初期化 ├── L127 require / app 初期化
├── L2884 Auth helpersWebAuthn dynamic import、インメモリレートリミッター ├── L2884 Auth helpersWebAuthn dynamic import、インメモリレートリミッター
├── L85126 JWT config + session helpers ├── L85126 JWT config + session helpers
├── L127173 Express middlewareCORS など) ├── L127173 Express middlewareCORS など)
├── L174260 DB pool (pg) + Gemini + API Key 認証マップ ├── L174300 DB pool (pg) + Gemini + API Key 認証マップ
├── L261481 共有ユーティリティextractSource、charset 正規化、SSRF guard、 │ ※ pool.max 15・タイムアウト設定・pool.on('error') 追加済み
│ fetchMeta、fetchFullTextViaJina、analyzeWithGemini ├── L261520 共有ユーティリティextractSource、charset 正規化、
├── L482721 initDBPostgreSQL スキーマ全定義 — 240行 │ isSsrfSafeNEW、fetchMetaSSRF+2MB上限追加
├── L722769 Express Router 生成 + /health │ fetchFullTextViaJinaSSRF+1MB上限追加、analyzeWithGemini
├── L521760 initDBPostgreSQL スキーマ全定義 — 240行
├── L761809 Express Router 生成 + /health
├── ── ルートグループ ───────────────────────────────────────────── ├── ── ルートグループ ─────────────────────────────────────────────
├── L7701106 Auth: Magic Link + session337行 ├── L8101146 Auth: Magic Link + session337行
├── L9261106 Auth: Google OAuth / GitHub OAuth181行※上記と連続 ├── L9661146 Auth: Google OAuth / GitHub OAuth181行※上記と連続
├── L11071354 Auth: Passkey / WebAuthn248行 ├── L11471394 Auth: Passkey / WebAuthn248行
│ 合計 Auth routes: 585行 │ 合計 Auth routes: 585行
├── L13551612 Brain articlesGET/POST/PATCH/DELETE + save + history = 258行 ├── L13951680 Brain articlesGET/POST/PATCH/DELETE + save即時保存+非同期AI+ history
├── L16131685 Journal routesposts + public + AI tag suggestion + upload = 170行 │ ※ POST /save: 即時INSERT → setImmediate() で fetchMeta/AI をバックグラウンド実行
├── L16861782 Site Config routes96行 ├── L16811760 Journal routesposts + public + AI tag suggestion + upload
├── L17831884 Habit routes102行 ├── L17611860 Site Config routes
├── L18851930 Pulse routes46行 ├── L18611960 Habit routes
├── L19311967 Lens history routes37行 ├── L19612010 Pulse routes
├── L20112050 Lens history routes
├── L19682118 Feed Media + Feed Articles routes151行 ├── L20512210 Feed Media + Feed Articles routes
├── L21192288 TTSVOICEVOXroutes + サーバー側自動プリウォーム170行 ├── L22112390 TTSVOICEVOXroutes + サーバー側自動プリウォーム
├── L22892320 Events前処理32行 ├── L23912430 Events前処理
├── L23212775 Together routesgroups / join / share / feed / comments / search = 455行 ├── L24312900 Together routesgroups / join / share / feed / comments / search
├── L26252775 Atlas proxyGitHub / Vercel / Tailscale scan = 151行※上記と連続 │ ※ POST /together/share: isSsrfSafe() 適用済み
│ ※ archiveShare(): isSsrfSafe() + 1MB上限適用済み
├── L27402900 Atlas proxyGitHub / Vercel / Tailscale scan※上記と連続
├── L27762884 Doorkeeper + connpassapp-level、router 外 = 109行 ├── L29013010 Doorkeeper + connpassapp-level、router 外
├── L28852889 静的ファイル(/uploads ├── L30113015 静的ファイル(/uploads
├── L28912989 Stripe webhook99行 ├── L30173115 Stripe webhook
└── L29903091 router マウント + サーバー起動 + Feed 背景ジョブ102行 └── L31163130 router マウント + サーバー起動 + Feed 背景ジョブ
``` ```
--- ---
@ -74,10 +80,10 @@ server.js (3091行)
| `genAI` (Gemini) | L191 | 2箇所 | brain.js のみ | | `genAI` (Gemini) | L191 | 2箇所 | brain.js のみ |
| `webauthnChallenges` (Map) | L40 | 15箇所 | auth routes のみ | | `webauthnChallenges` (Map) | L40 | 15箇所 | auth routes のみ |
| `rateLimitStores` / `rateLimit()` | L51 | 多数 | auth routes 中心 | | `rateLimitStores` / `rateLimit()` | L51 | 多数 | auth routes 中心 |
| `isSsrfSafe()` | L294 | 5箇所 | brain.js / together.js | | `isSsrfSafe()` | L294付近 | 7箇所 | brain.js / together.js |
| `fetchMeta()` | L304 | brain + together | | `fetchMeta()` | L304付近 | brain + together |
| `fetchFullTextViaJina()` | L372 | brain + together | | `fetchFullTextViaJina()` | L372付近 | brain + together |
| `analyzeWithGemini()` | L416 | brain のみ | | `analyzeWithGemini()` | L416付近 | brain のみ |
| `JWT_SECRET` | L86 | auth 全体 | | `JWT_SECRET` | L86 | auth 全体 |
| `WEBAUTHN_RP_*` | L92 | auth/passkey のみ | | `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)); pool.on('error', (err) => console.error('[DB] Unexpected pool error:', err.message));
async function initDB() { /* server.js L482721 をそのまま移動 */ } async function initDB() { /* server.js の initDB をそのまま移動 */ }
module.exports = { pool, initDB }; module.exports = { pool, initDB };
``` ```
@ -176,6 +182,7 @@ module.exports = { rateLimit, webauthnChallenges };
**lib/fetch.js** **lib/fetch.js**
```js ```js
// isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini, extractSource // isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini, extractSource
// ※ isSsrfSafe は 2026-04-10 に server.js 内で実装済み。コピーして移動するだけ。
module.exports = { isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini }; module.exports = { isSsrfSafe, fetchMeta, fetchFullTextViaJina, analyzeWithGemini };
``` ```
@ -233,15 +240,74 @@ app.use('/api/stripe', stripeRouter);
### フェーズ 2 の注意点(ハマりやすい箇所) ### フェーズ 2 の注意点(ハマりやすい箇所)
1. **feed バックグラウンドジョブ**L29983091は 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 の二重定義**L27762884 が 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の後に行う。 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. 即時 INSERTdomain を仮タイトルとして使用)
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 ユーザー変更が伴う。別タスク | | DB_USER: 'gitea' の修正 | サーバー側 PostgreSQL ユーザー変更が伴う。別タスク |
| console.log 整理 | 分割と無関係。別タスク | | console.log 整理 | 分割と無関係。別タスク |
| インメモリ state の永続化 | スキーマ変更が必要。別タスクCLAUDE.md 要確認事項) | | インメモリ state の永続化Redis 等) | `webauthnChallenges` / `rateLimitStores` をDB/Redisに移行。スキーマ変更が必要・**mai 確認必須** |
--- ---
## 7. 検討中の追加改善(分割後の次フェーズ候補) ## 9. 検討中の追加改善(分割後の次フェーズ候補)
- **DB_USER を 'gitea' から 'posimai' に変更**PostgreSQL ユーザー作成が必要) - **DB_USER を 'gitea' から 'posimai' に変更**PostgreSQL ユーザー作成が必要)
- **webauthnChallenges / rateLimitStores の Redis 移行**(再起動耐性・スケール対応) - **webauthnChallenges / rateLimitStores の Redis 移行**(再起動耐性・スケール対応)
- **console.log を構造化ログpino 等)に統一** - **console.log を構造化ログpino 等)に統一**
- **ルートごとのエラーハンドリング統一**(現状は各ルートで個別 try/catch - **ルートごとのエラーハンドリング統一**(現状は各ルートで個別 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 は「フェーズ X 完了」「完了日」「担当 AI」を末尾に追記してください。*
---
## 完了履歴
| フェーズ | 完了日 | 担当 AI | 内容 |
|---------|-------|--------|------|
| セキュリティ修正フェーズ0相当 | 2026-04-10 | Claude Sonnet 4.6 | SSRF ガード・サイズ上限・pool 設定改善・即時保存パターン全7件 |