posimai-root/docs/server-refactor-plan.md

365 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# server.js リファクタリング計画
最終更新: 2026-04-10
対象: Claude Code / Cursor / Gemini / 全 AI エージェント
**このドキュメントは `server.js` の現状分析・分割設計・実施タイミング判断基準を一元管理します。
リファクタリング作業を始める前に必ず読んでください。**
---
## 0. 現状サマリー
| 項目 | 内容 |
|------|------|
| ファイル | `/server.js`(リポジトリルート) |
| 行数 | **約3130行**2026-04-10 計測・セキュリティ修正追加後) |
| 役割 | VPS 上で動く Node.js/Express バックエンド。全アプリの API を1ファイルに集約 |
| 問題 | テストが書けない・障害切り分けが困難・新機能追加のたびにファイルが肥大化 |
| 状態 | **本番稼働中・障害なし。2026-04-10 にセキュリティ修正7件適用済み。** |
| セキュリティ | SSRF ガード追加・レスポンスサイズ上限追加・DB pool 設定改善済み |
---
## 1. 現在の内部構造(セクション別行数)
```
server.js (約3130行)
├── L127 require / app 初期化
├── L2884 Auth helpersWebAuthn dynamic import、インメモリレートリミッター
├── L85126 JWT config + session helpers
├── L127173 Express middlewareCORS など)
├── L174300 DB pool (pg) + Gemini + API Key 認証マップ
│ ※ pool.max 15・タイムアウト設定・pool.on('error') 追加済み
├── L261520 共有ユーティリティextractSource、charset 正規化、
│ isSsrfSafeNEW、fetchMetaSSRF+2MB上限追加
│ fetchFullTextViaJinaSSRF+1MB上限追加、analyzeWithGemini
├── L521760 initDBPostgreSQL スキーマ全定義 — 240行
├── L761809 Express Router 生成 + /health
├── ── ルートグループ ─────────────────────────────────────────────
├── L8101146 Auth: Magic Link + session337行
├── L9661146 Auth: Google OAuth / GitHub OAuth181行※上記と連続
├── L11471394 Auth: Passkey / WebAuthn248行
│ 合計 Auth routes: 585行
├── L13951680 Brain articlesGET/POST/PATCH/DELETE + save即時保存+非同期AI+ history
│ ※ POST /save: 即時INSERT → setImmediate() で fetchMeta/AI をバックグラウンド実行
├── L16811760 Journal routesposts + public + AI tag suggestion + upload
├── L17611860 Site Config routes
├── L18611960 Habit routes
├── L19612010 Pulse routes
├── L20112050 Lens history routes
├── L20512210 Feed Media + Feed Articles routes
├── L22112390 TTSVOICEVOXroutes + サーバー側自動プリウォーム
├── L23912430 Events前処理
├── L24312900 Together routesgroups / join / share / feed / comments / search
│ ※ POST /together/share: isSsrfSafe() 適用済み
│ ※ archiveShare(): isSsrfSafe() + 1MB上限適用済み
├── L27402900 Atlas proxyGitHub / Vercel / Tailscale scan※上記と連続
├── L29013010 Doorkeeper + connpassapp-level、router 外)
├── L30113015 静的ファイル(/uploads
├── L30173115 Stripe webhook
└── L31163130 router マウント + サーバー起動 + Feed 背景ジョブ
```
---
## 2. 共有変数の依存マップ
分割時に「どの変数をどのファイルに置くか」の判断基準。
| 変数 / 関数 | 宣言行 | 参照箇所数 | 参照元セクション |
|------------|--------|-----------|----------------|
| `pool` (pg Pool) | L174 | **108箇所** | 全ルートファイル |
| `authMiddleware` | L197付近 | **46箇所** | 全認証必須ルート |
| `genAI` (Gemini) | L191 | 2箇所 | brain.js のみ |
| `webauthnChallenges` (Map) | L40 | 15箇所 | auth routes のみ |
| `rateLimitStores` / `rateLimit()` | L51 | 多数 | auth routes 中心 |
| `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 のみ |
**結論**: `pool``authMiddleware` を共有モジュールに切り出せば、残りのルート分割は機械的に行える。
---
## 3. 目標とする分割後の構造
```
server.js ← 100行以内エントリポイント + マウントのみ)
lib/
db.js ← pool + initDB をエクスポート
auth.js ← JWT_SECRET / createToken / authMiddleware / session helpers
rateLimit.js ← インメモリレートリミッターwebauthnChallenges も含める)
fetch.js ← isSsrfSafe / fetchMeta / fetchFullTextViaJina / analyzeWithGemini
routes/
auth.js ← Magic Link + OAuthGoogle/GitHub+ Passkey/WebAuthn
brain.js ← articles / save / history / AI 分析
journal.js ← journal posts + site config
habit.js ← habit + pulse + lens history
feed.js ← feed media + feed articles + TTS + feed バックグラウンドジョブ
together.js ← groups / join / share / comments / search
events.js ← Doorkeeper + connpassapp-level も含めて統合)
atlas.js ← GitHub / Vercel / Tailscale scan proxy
stripe.js ← webhook のみ
```
**各ファイルの想定行数**: 100600行。神ファイルは完全に消える。
---
## 4. 実施タイミングの判断基準
### やらなくていい条件(すべて揃っている間は待つ)
- 本番で分割に起因する障害が出ていない
- 新しいルートグループを追加する予定がない
- 商用化タスクStripe 課金・ユーザー招待・モバイル)が残っている
### 開始するべきタイミングどれか1つ該当したら着手
| トリガー | 理由 |
|---------|------|
| **新ルートグループを追加するとき**(例: posimai-station の API | 新ファイルを作るついでに既存も整理。追加コストがほぼゼロ |
| **auth.js だけバグが続いて server.js 内のデバッグが辛い** | 問題のある1ルートだけ先に切り出す部分分割でも OK |
| **Node.js バージョンアップや pg ライブラリ更新のとき** | どうせ動作確認が必要なので、そのタイミングで進める |
| **server.js が 3500行を超えたとき** | 純粋に限界。この時点で強制着手 |
---
## 5. 実施手順(着手するときはこの順番で)
### フェーズ 1: 共有レイヤーの切り出し低リスク・12時間
ルートは一切触らない。共有変数をモジュールに切り出すだけ。
```bash
mkdir lib routes
```
**lib/db.js**
```js
'use strict';
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'gitea', // TODO: 'posimai' に変更
password: process.env.DB_PASSWORD || '',
database: 'posimai_brain',
max: 15,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => console.error('[DB] Unexpected pool error:', err.message));
async function initDB() { /* server.js の initDB をそのまま移動 */ }
module.exports = { pool, initDB };
```
**lib/auth.js**
```js
// JWT_SECRET, JWT_TTL_SECONDS, createToken, authMiddleware, session helpers
module.exports = { JWT_SECRET, createToken, authMiddleware };
```
**lib/rateLimit.js**
```js
// rateLimitStores, rateLimit(), webauthnChallenges, cleanup interval
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 };
```
server.js 側の変更は `require` 置き換えのみ:
```js
const { pool, initDB } = require('./lib/db');
const { authMiddleware } = require('./lib/auth');
```
**動作確認**:
```bash
node server.js
curl http://localhost:PORT/api/health
```
---
### フェーズ 2: ルートの切り出し中リスク・1ルート1PR
**独立度が高い順(この順番で進める)**:
```
1. routes/stripe.js 99行 依存: pool のみ。最も安全
2. routes/atlas.js 151行 依存: なし外部API プロキシのみ)
3. routes/events.js 141行 依存: なし外部API フェッチのみ)
4. routes/together.js 455行 依存: pool + authMiddleware + isSsrfSafe
5. routes/feed.js 321行 依存: pool + authMiddlewarefeed job は起動後アタッチ)
6. routes/habit.js 185行 依存: pool + authMiddleware
7. routes/brain.js 258行 依存: pool + authMiddleware + fetch + genAI
8. routes/journal.js 266行 依存: pool + authMiddleware
9. routes/auth.js 585行 依存: webauthnChallenges + rateLimitフェーズ1完了後
```
**各ルートファイルの形式**:
```js
'use strict';
const { Router } = require('express');
const { pool } = require('../lib/db');
const { authMiddleware } = require('../lib/auth');
// ...
const router = Router();
router.get('/xxx', authMiddleware, async (req, res) => { ... });
module.exports = router;
```
**server.js でのマウント**:
```js
const stripeRouter = require('./routes/stripe');
app.use('/brain/api/stripe', stripeRouter);
app.use('/api/stripe', stripeRouter);
```
---
### フェーズ 2 の注意点(ハマりやすい箇所)
1. **feed バックグラウンドジョブ**は routes/feed.js に移すが、`startFeedJob()` の呼び出しは server.js の起動後に行う必要がある。`module.exports = { router, startFeedJob }` の形式にする。
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. 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. やらないこと(スコープ外)
| 項目 | 理由 |
|------|------|
| TypeScript 化 | 全書き直しになる。現在の優先度では対費用効果が出ない |
| テストコード追加 | 分割と同時にやると範囲が広がりすぎる。分割後の別タスク |
| DB_USER: 'gitea' の修正 | サーバー側 PostgreSQL ユーザー変更が伴う。別タスク |
| console.log 整理 | 分割と無関係。別タスク |
| インメモリ state の永続化Redis 等) | `webauthnChallenges` / `rateLimitStores` をDB/Redisに移行。スキーマ変更が必要・**mai 確認必須** |
---
## 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件 |