// ============================================ // Posimai Brain API — Synology Docker Server // ============================================ // Port: 8090 // Route prefix: /brain/api (Tailscale Funnel) and /api (local dev) // // ENV VARS (set in docker-compose.yml): // DB_HOST, DB_PORT, DB_USER, DB_PASSWORD // GEMINI_API_KEY // API_KEYS = "pk_maita_xxx:maita,pk_partner_xxx:partner,pk_musume_xxx:musume" // ALLOWED_ORIGINS = 追加許可したいオリジン(カンマ区切り)※ posimai-*.vercel.app は自動許可 // ============================================ const express = require('express'); const cors = require('cors'); const { Pool } = require('pg'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const { parse } = require('node-html-parser'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const os = require('os'); const { execSync } = require('child_process'); let RssParser = null; try { RssParser = require('rss-parser'); } catch (_) { console.warn('[Feed] rss-parser not found, background fetch disabled'); } // ── Auth: WebAuthn (ESM dynamic import) ───────────────────────────── let webauthn = null; async function loadWebauthn() { try { webauthn = await import('@simplewebauthn/server'); console.log('[Auth] SimpleWebAuthn loaded'); } catch (e) { console.warn('[Auth] SimpleWebAuthn not available:', e.message); } } // In-memory WebAuthn challenge store (TTL: 5 min) const webauthnChallenges = new Map(); setInterval(() => { const now = Date.now(); for (const [k, v] of webauthnChallenges) { if (v.expiresAt < now) webauthnChallenges.delete(k); } }, 10 * 60 * 1000); // ── 汎用インメモリレートリミッター ────────────────────────────── // usage: checkRateLimit(store, key, maxCount, windowMs) // 返り値: true = 制限内、false = 超過 const rateLimitStores = {}; function checkRateLimit(storeName, key, maxCount, windowMs) { if (!rateLimitStores[storeName]) rateLimitStores[storeName] = new Map(); const store = rateLimitStores[storeName]; const now = Date.now(); const entry = store.get(key); if (!entry || now - entry.windowStart >= windowMs) { store.set(key, { count: 1, windowStart: now }); return true; } if (entry.count >= maxCount) return false; entry.count++; return true; } // 定期クリーンアップ(1時間ごと) setInterval(() => { const now = Date.now(); for (const store of Object.values(rateLimitStores)) { for (const [k, v] of store) { if (now - v.windowStart > 60 * 60 * 1000) store.delete(k); } } }, 60 * 60 * 1000); // ── ユーティリティ ─────────────────────────────────────────────────── function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ── Auth: JWT config ──────────────────────────────────────────────── const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-CHANGE-IN-PRODUCTION'; if (!process.env.JWT_SECRET) { console.error('[SECURITY] JWT_SECRET is not set. Using insecure default. Set JWT_SECRET env var in production!'); } const JWT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days // WebAuthn relying party config (from env) const WEBAUTHN_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'Posimai'; const WEBAUTHN_RP_ID = process.env.WEBAUTHN_RP_ID || 'localhost'; const WEBAUTHN_ORIGINS = (process.env.WEBAUTHN_ORIGINS || 'http://localhost:3000') .split(',').map(o => o.trim()).filter(Boolean); const MAGIC_LINK_BASE_URL = process.env.MAGIC_LINK_BASE_URL || 'http://localhost:3000'; // ── Auth: session helpers ──────────────────────────────────────────── async function createSessionJWT(userId) { const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + JWT_TTL_SECONDS * 1000); const tokenHash = crypto.createHash('sha256').update(sessionId).digest('hex'); await pool.query( `INSERT INTO auth_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)`, [sessionId, userId, tokenHash, expiresAt] ); // plan を JWT に含める(各アプリがプレミアム判定できるよう) let plan = 'free'; try { const r = await pool.query(`SELECT plan FROM users WHERE user_id = $1`, [userId]); plan = r.rows[0]?.plan || 'free'; } catch (_) {} return jwt.sign({ userId, sid: sessionId, plan }, JWT_SECRET, { expiresIn: JWT_TTL_SECONDS }); } const app = express(); // Stripe Webhook は raw body が必要なため、webhook パスのみ json パースをスキップ app.use((req, res, next) => { if (req.path === '/brain/api/stripe/webhook' || req.path === '/api/stripe/webhook') { return next(); } express.json({ limit: '10mb' })(req, res, next); }); // ── CORS ────────────────────────────────── // ALLOWED_ORIGINS 環境変数(カンマ区切り)+ 開発用ローカル // posimai-*.vercel.app は新アプリ追加のたびに変更不要(ワイルドカード許可) const extraOrigins = (process.env.ALLOWED_ORIGINS || '') .split(',').map(o => o.trim()).filter(Boolean); function isAllowedOrigin(origin) { if (!origin) return false; // origin なしは拒否(CSRF 対策) if (process.env.NODE_ENV !== 'production' && /^http:\/\/localhost(:\d+)?$/.test(origin)) return true; // localhost 開発のみ if (/^https:\/\/posimai-[\w-]+\.vercel\.app$/.test(origin)) return true; // 全 Posimai アプリ(英数字・ハイフンのみ) if (/^https:\/\/[\w-]+\.posimai\.soar-enrich\.com$/.test(origin)) return true; // 独自ドメイン配下 if (origin === 'https://posimai.soar-enrich.com') return true; // Dashboard if (extraOrigins.includes(origin)) return true; // 追加許可 return false; } // Chrome の Private Network Access ポリシー対応(cors より前に置く必要がある) // cors() が OPTIONS preflight を先に完結させるため、後に置いても実行されない app.use((req, res, next) => { if (req.headers['access-control-request-private-network']) { res.setHeader('Access-Control-Allow-Private-Network', 'true'); } next(); }); // /health はサーバー間プロキシ経由で origin なしリクエストが来るため先に CORS * で通す app.use((req, res, next) => { if (req.path === '/brain/api/health' || req.path === '/api/health') { res.setHeader('Access-Control-Allow-Origin', '*'); } next(); }); app.use(cors({ origin: (origin, cb) => { if (!origin) { // origin なし = サーバー間リクエスト(curl / Node fetch 等)。/health のみ通過させる // それ以外のエンドポイントはCSRF対策で拒否 return cb(null, false); } if (isAllowedOrigin(origin)) cb(null, true); else cb(new Error('CORS not allowed')); }, methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); // ── PostgreSQL ──────────────────────────── const pool = new Pool({ host: process.env.DB_HOST || 'db', port: parseInt(process.env.DB_PORT || '5432'), user: process.env.DB_USER || 'gitea', password: process.env.DB_PASSWORD || '', database: 'posimai_brain', max: 5 }); // ── Gemini ──────────────────────────────── const genAI = process.env.GEMINI_API_KEY ? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null; // Together 専用インスタンス(メインキーを共用) const genAITogether = genAI; // ── API Key 認証 ────────────────────────── // API_KEYS="pk_maita_abc:maita,pk_partner_def:partner" const KEY_MAP = {}; (process.env.API_KEYS || '').split(',').forEach(pair => { const [key, userId] = pair.trim().split(':'); if (key && userId) KEY_MAP[key.trim()] = userId.trim(); }); function authMiddleware(req, res, next) { let token = ''; // 1. ヘッダーからの取得 const auth = req.headers.authorization || ''; if (auth.toLowerCase().startsWith('bearer ')) { token = auth.substring(7).trim(); } // 2. クエリパラメータからの取得 (Bookmarklet等) else if (req.query.key) { token = req.query.key.trim(); } if (!token) return res.status(401).json({ error: '認証エラー: トークンがありません' }); // JWT session token (3-part dot format, not pk_ prefix) if (!token.startsWith('pk_') && token.includes('.')) { try { const payload = jwt.verify(token, JWT_SECRET); req.userId = payload.userId; req.authType = 'session'; return next(); } catch (e) { return res.status(401).json({ error: '認証エラー: セッションが無効または期限切れです' }); } } // API key (internal users — skip purchase check) const userId = KEY_MAP[token]; if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' }); req.userId = userId; req.authType = 'apikey'; next(); } // 購入済みチェックミドルウェア(JWT セッションユーザーのみ適用) // API キーユーザー(内部)はスキップ async function purchaseMiddleware(req, res, next) { if (req.authType === 'apikey') return next(); // 内部ユーザーはスキップ try { const result = await pool.query( `SELECT purchased_at FROM users WHERE user_id = $1`, [req.userId] ); if (result.rows.length > 0 && result.rows[0].purchased_at) { return next(); } return res.status(402).json({ error: '購入が必要です', store_url: 'https://posimai-store.vercel.app/index-c.html' }); } catch (e) { console.error('[Purchase] DB error:', e.message); return res.status(500).json({ error: 'サーバーエラーが発生しました' }); } } // ── ソース抽出 ──────────────────────────── const SOURCE_MAP = { 'zenn.dev': 'Zenn', 'qiita.com': 'Qiita', 'x.com': 'X', 'twitter.com': 'X', 'note.com': 'note', 'dev.to': 'DEV', 'nikkei.com': '日経', 'nikkei.co.jp': '日経', 'gigazine.net': 'GIGAZINE', 'gizmodo.jp': 'GIZMODE', 'developers.io': 'DevelopersIO', 'classmethod.jp': 'DevelopersIO', 'github.com': 'GitHub', 'medium.com': 'Medium', 'techcrunch.com': 'TechCrunch', 'vercel.com': 'Vercel', }; function extractSource(url) { try { const host = new URL(url).hostname.replace(/^www\./, ''); for (const [domain, label] of Object.entries(SOURCE_MAP)) { if (host === domain || host.endsWith('.' + domain)) return label; } return host; } catch { return 'unknown'; } } // ── 文字コード正規化 ───────────────────────── function normalizeCharset(raw) { const e = (raw || '').toLowerCase().replace(/['"]/g, '').trim(); if (['shift_jis', 'shift-jis', 'sjis', 'x-sjis', 'ms_kanji', 'ms932', 'windows-31j', 'csshiftjis'].includes(e)) return 'shift_jis'; if (['euc-jp', 'euc_jp', 'x-euc-jp', 'cseucpkdfmtjapanese'].includes(e)) return 'euc-jp'; if (e.startsWith('utf')) return 'utf-8'; return 'utf-8'; } // ── OGP フェッチ ─────────────────────────── async function fetchMeta(url) { try { const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PosimaiBot/1.0)' }, signal: AbortSignal.timeout(6000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const buffer = await res.arrayBuffer(); // 文字コード判定: 1) Content-Typeヘッダー優先 2) HTMLメタタグ確認 // iso-8859-1はバイト値0-255をロスレスでデコードするためcharset検出に最適 let encoding = 'utf-8'; const contentType = res.headers.get('content-type') || ''; const ctMatch = contentType.match(/charset=([^\s;]+)/i); if (ctMatch) { encoding = normalizeCharset(ctMatch[1]); } if (encoding === 'utf-8') { const headSnippet = new TextDecoder('iso-8859-1').decode(new Uint8Array(buffer).slice(0, 2000)); const metaMatch = headSnippet.match(/charset=["']?([^"'\s;>]+)/i); if (metaMatch) encoding = normalizeCharset(metaMatch[1]); } let html; try { html = new TextDecoder(encoding).decode(buffer); } catch { // TextDecoder が対象エンコーディング非対応の場合は UTF-8 フォールバック html = new TextDecoder('utf-8', { fatal: false }).decode(buffer); } const doc = parse(html); const og = (p) => doc.querySelector(`meta[property="${p}"]`)?.getAttribute('content') || ''; const meta = (n) => doc.querySelector(`meta[name="${n}"]`)?.getAttribute('content') || ''; const title = og('og:title') || doc.querySelector('title')?.text || url; const desc = og('og:description') || meta('description') || ''; const img = og('og:image') || ''; // Google Favicon API(優先)→ favicon.ico(フォールバック) const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`; return { title: title.trim().slice(0, 300), desc: desc.trim().slice(0, 500), ogImage: img, favicon: faviconUrl }; } catch { let host = ''; try { try { host = new URL(url).hostname; } catch { } return { title: url.slice(0, 300), desc: '', ogImage: '', favicon: host ? `https://www.google.com/s2/favicons?domain=${host}&sz=32` : '' }; } catch { return { title: url.slice(0, 300), desc: '', ogImage: '', favicon: '' }; } } } // ── Jina Reader API フェッチ(新規追加)─── async function fetchFullTextViaJina(url) { try { console.log(`[Brain API] Fetching full text via Jina Reader for: ${url}`); const jinaResponse = await fetch(`https://r.jina.ai/${url}`, { headers: { 'User-Agent': 'Mozilla/5.0 Posimai Brain Bot' }, signal: AbortSignal.timeout(15000) }); if (!jinaResponse.ok) { console.warn(`[Brain API] Jina Reader returned status ${jinaResponse.status}`); return null; } let markdown = await jinaResponse.text(); // Markdown Content: マーカーの後ろを抽出 const contentMarker = 'Markdown Content:'; const contentIndex = markdown.indexOf(contentMarker); if (contentIndex !== -1) { markdown = markdown.substring(contentIndex + contentMarker.length).trim(); } // 画像参照を除去(Readerと同じロジック) markdown = markdown.replace(/!\[Image\s+\d+[^\]]*\]\([^)]*\)/gmi, ''); markdown = markdown.replace(/!\[Image\s+\d+[^\]]*\]/gmi, ''); markdown = markdown.replace(/^\s*\*?\s*!\[?Image\s+\d+[^\n]*/gmi, ''); markdown = markdown.replace(/\[\]\([^)]*\)/gm, ''); console.log(`[Brain API] ✓ Fetched ${markdown.length} chars via Jina Reader`); return markdown; } catch (error) { console.error('[Brain API] Jina Reader fetch failed:', error.message); return null; } } // ── Gemini 分析 ─────────────────────────── const TOPIC_CANDIDATES = [ 'AI', 'React', 'Next.js', 'TypeScript', 'Node.js', 'Flutter', 'Docker', 'PostgreSQL', '日本酒', 'ガジェット', 'キャリア', 'その他' ]; function smartExtract(text, maxLen) { if (!text || text.length <= maxLen) return text || ''; const halfLen = Math.floor(maxLen / 2); const front = text.substring(0, halfLen); const back = text.substring(text.length - halfLen); return front + '\n\n[...中略...]\n\n' + back; } async function analyzeWithGemini(title, fullText, url) { if (!genAI) return { summary: (fullText || '').slice(0, 120) || '(要約なし)', topics: ['その他'], readingTime: 3 }; try { const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-lite', generationConfig: { responseMimeType: 'application/json' } }); const prompt = `記事分析してJSONで返答: タイトル: ${title.slice(0, 100)} 本文: ${smartExtract(fullText || '', 5000)} {"summary":"3文要約","topics":["最大2個"],"readingTime":3} 候補: ${TOPIC_CANDIDATES.slice(0, 15).join(',')}`; const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Gemini timeout')), 15000) ); let result; result = await Promise.race([ model.generateContent(prompt), timeoutPromise ]); let raw = result.response.text().trim(); // ```json や ``` で囲まれている場合の除去を堅牢化 raw = raw.replace(/^```(json)?/i, '').replace(/```$/i, '').trim(); const parsed = JSON.parse(raw); const validTopics = (parsed.topics || []) .filter(t => TOPIC_CANDIDATES.includes(t)).slice(0, 2); if (validTopics.length === 0) validTopics.push('その他'); return { summary: String(parsed.summary || '').slice(0, 300), topics: validTopics, readingTime: Math.max(1, parseInt(parsed.readingTime) || 3) }; } catch (e) { console.error('[Gemini] FULL ERROR:', e); if (typeof result !== 'undefined' && result?.response) { console.error('[Gemini] Raw response:', result.response.text()); } return { summary: 'AI分析に失敗しました。しばらく後にお試しください。', topics: ['その他'], readingTime: 3 }; } } // ── DB 初期化 (起動時に自動実行) ───────── async function initDB() { // 1. posimai_brain DB を作成(なければ) const admin = new Pool({ host: process.env.DB_HOST || 'db', port: parseInt(process.env.DB_PORT || '5432'), user: process.env.DB_USER || 'gitea', password: process.env.DB_PASSWORD || '', database: 'gitea' }); try { await admin.query('CREATE DATABASE posimai_brain'); console.log('[DB] Created database posimai_brain'); } catch (e) { if (e.code !== '42P04') throw e; console.log('[DB] Database already exists, skipping'); } finally { await admin.end(); } // 2. テーブル定義 — 1文ずつ実行(マルチステートメントのエラーを防止) const schema = [ `CREATE TABLE IF NOT EXISTS users ( user_id VARCHAR(50) PRIMARY KEY, name VARCHAR(100) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS articles ( id SERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, url TEXT NOT NULL, title TEXT, summary TEXT, topics TEXT[] DEFAULT '{}', source VARCHAR(100), status VARCHAR(20) DEFAULT 'inbox', previous_status VARCHAR(20) DEFAULT 'inbox', reading_time INT DEFAULT 3, favicon TEXT, og_image TEXT, saved_at TIMESTAMPTZ DEFAULT NOW(), read_at TIMESTAMPTZ, full_text TEXT, UNIQUE(user_id, url) )`, `CREATE INDEX IF NOT EXISTS idx_articles_user_id ON articles(user_id)`, `CREATE INDEX IF NOT EXISTS idx_articles_saved_at ON articles(saved_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(user_id, status)`, `CREATE INDEX IF NOT EXISTS idx_articles_topics ON articles USING GIN(topics)`, `CREATE TABLE IF NOT EXISTS reading_history ( id SERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL, url TEXT NOT NULL, title TEXT, domain VARCHAR(200), read_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(user_id, url) )`, `CREATE TABLE IF NOT EXISTS journal_posts ( id SERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, title TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', tags TEXT[] DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_journal_user_id ON journal_posts(user_id)`, `CREATE INDEX IF NOT EXISTS idx_journal_updated ON journal_posts(updated_at DESC)`, // site_config: CMS サイト設定テーブル `CREATE TABLE IF NOT EXISTS site_config ( key VARCHAR(50) PRIMARY KEY, value JSONB NOT NULL DEFAULT '{}', updated_at TIMESTAMPTZ DEFAULT NOW() )`, // ── Habit ────────────────────────────── `CREATE TABLE IF NOT EXISTS habit_habits ( id SERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, icon VARCHAR(50) NOT NULL DEFAULT 'check', position INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS habit_log ( user_id VARCHAR(50) NOT NULL, habit_id INTEGER NOT NULL REFERENCES habit_habits(id) ON DELETE CASCADE, log_date DATE NOT NULL, PRIMARY KEY (user_id, habit_id, log_date) )`, // ── Pulse ────────────────────────────── `CREATE TABLE IF NOT EXISTS pulse_log ( user_id VARCHAR(50) NOT NULL, log_date DATE NOT NULL, mood SMALLINT CHECK (mood BETWEEN 1 AND 5), energy SMALLINT CHECK (energy BETWEEN 1 AND 5), focus SMALLINT CHECK (focus BETWEEN 1 AND 5), note TEXT NOT NULL DEFAULT '', updated_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (user_id, log_date) )`, // ── Lens ─────────────────────────────── `CREATE TABLE IF NOT EXISTS lens_history ( id SERIAL PRIMARY KEY, user_id VARCHAR(50) NOT NULL, filename VARCHAR(255), exif_data JSONB NOT NULL DEFAULT '{}', thumbnail TEXT, scanned_at TIMESTAMPTZ DEFAULT NOW() )`, // ── Together ──────────────────────────── `CREATE TABLE IF NOT EXISTS together_groups ( id SERIAL PRIMARY KEY, invite_code VARCHAR(8) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS together_members ( group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE, username VARCHAR(50) NOT NULL, joined_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (group_id, username) )`, `CREATE TABLE IF NOT EXISTS together_shares ( id SERIAL PRIMARY KEY, group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE, shared_by VARCHAR(50) NOT NULL, url TEXT, title TEXT, message TEXT NOT NULL DEFAULT '', og_image TEXT, tags TEXT[] DEFAULT '{}', full_content TEXT, summary TEXT, archive_status VARCHAR(10) NOT NULL DEFAULT 'pending', shared_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_together_shares_group ON together_shares(group_id, shared_at DESC)`, `CREATE TABLE IF NOT EXISTS together_reactions ( share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE, username VARCHAR(50) NOT NULL, type VARCHAR(20) NOT NULL DEFAULT 'like', created_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (share_id, username, type) )`, `CREATE TABLE IF NOT EXISTS together_comments ( id SERIAL PRIMARY KEY, share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE, username VARCHAR(50) NOT NULL, body TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`, // ── Auth ─────────────────────────────────── `CREATE TABLE IF NOT EXISTS magic_link_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL, token VARCHAR(64) UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() )`, `CREATE INDEX IF NOT EXISTS idx_mlt_token ON magic_link_tokens(token)`, `CREATE INDEX IF NOT EXISTS idx_mlt_email ON magic_link_tokens(email, expires_at)`, `CREATE TABLE IF NOT EXISTS passkey_credentials ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, credential_id TEXT UNIQUE NOT NULL, public_key BYTEA NOT NULL, counter BIGINT DEFAULT 0, device_type VARCHAR(32), transports TEXT[], display_name VARCHAR(255), created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ )`, `CREATE INDEX IF NOT EXISTS idx_pk_user ON passkey_credentials(user_id)`, `CREATE TABLE IF NOT EXISTS auth_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, token_hash VARCHAR(64) UNIQUE NOT NULL, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), last_seen_at TIMESTAMPTZ DEFAULT NOW(), user_agent TEXT, ip_address INET )`, `CREATE INDEX IF NOT EXISTS idx_as_token_hash ON auth_sessions(token_hash)`, `CREATE INDEX IF NOT EXISTS idx_as_user_id ON auth_sessions(user_id)`, `CREATE TABLE IF NOT EXISTS magic_link_rate_limit ( email VARCHAR(255) PRIMARY KEY, attempt_count INT DEFAULT 1, window_start TIMESTAMPTZ DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS webauthn_user_handles ( user_id VARCHAR(50) PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, user_handle TEXT UNIQUE NOT NULL )`, ]; for (const sql of schema) { await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message)); } // 3. マイグレーション — 既存テーブルへのカラム追加(全て安全に実行) const migrations = [ `ALTER TABLE articles ADD COLUMN IF NOT EXISTS previous_status VARCHAR(20) DEFAULT 'inbox'`, `ALTER TABLE articles ADD COLUMN IF NOT EXISTS full_text TEXT`, `ALTER TABLE journal_posts ADD COLUMN IF NOT EXISTS published BOOLEAN NOT NULL DEFAULT FALSE`, `CREATE INDEX IF NOT EXISTS idx_journal_published ON journal_posts(published)`, // site_config をユーザー別に分離(既存データは maita に帰属) `ALTER TABLE site_config ADD COLUMN IF NOT EXISTS user_id VARCHAR(50) NOT NULL DEFAULT 'maita'`, `ALTER TABLE site_config DROP CONSTRAINT IF EXISTS site_config_pkey`, `ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`, // together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理 // ── Auth migrations ─────────────────────── `ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMPTZ`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'free'`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT`, ]; for (const sql of migrations) { await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message)); } // 4. 初期ユーザー (API_KEYSから) for (const [, userId] of Object.entries(KEY_MAP)) { await pool.query( `INSERT INTO users (user_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [userId, userId] ); } console.log('[DB] Schema ready. Users:', Object.values(KEY_MAP).join(', ')); } // ── ルーター ────────────────────────────── function buildRouter() { const r = express.Router(); // ヘルスチェック(Station コックピット向けに拡張) // 認証なし: 最小限レスポンス(外部監視ツール向け) // 認証あり(API Key / JWT): 詳細システム情報を追加 r.get('/health', (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); const base = { status: 'ok', timestamp: new Date().toISOString() }; // 認証確認(失敗しても 401 にせず最小レスポンスを返す) let authenticated = false; const auth = req.headers.authorization || ''; const token = auth.toLowerCase().startsWith('bearer ') ? auth.substring(7).trim() : (req.query.key || ''); if (token) { if (KEY_MAP[token]) { authenticated = true; } else { try { jwt.verify(token, JWT_SECRET); authenticated = true; } catch (_) {} } } if (!authenticated) return res.json(base); const mem = os.freemem(), total = os.totalmem(); let disk = null; try { const df = execSync('df -B1 / 2>/dev/null', { timeout: 2000 }).toString(); const p = df.trim().split('\n')[1].split(/\s+/); disk = { total_gb: Math.round(parseInt(p[1])/1e9*10)/10, used_gb: Math.round(parseInt(p[2])/1e9*10)/10, use_pct: Math.round(parseInt(p[2])/parseInt(p[1])*100) }; } catch(_) {} res.json({ ...base, gemini: !!genAI, uptime_s: Math.floor(os.uptime()), load_avg: os.loadavg().map(l => Math.round(l * 100) / 100), mem_used_mb: Math.round((total - mem) / 1024 / 1024), mem_total_mb: Math.round(total / 1024 / 1024), disk, }); }); // 認証テスト (UI用) r.get('/auth-test', authMiddleware, (req, res) => { res.json({ ok: true, userId: req.userId }); }); // ── Auth: Magic Link ───────────────────────────────────────────── // POST /api/auth/magic-link/send r.post('/auth/magic-link/send', async (req, res) => { const { email } = req.body; if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'メールアドレスが無効です' }); } const normalizedEmail = email.toLowerCase().trim(); try { // Rate limit: 3 requests per 10 min per email const rl = await pool.query( `SELECT attempt_count, window_start FROM magic_link_rate_limit WHERE email = $1`, [normalizedEmail] ); if (rl.rows.length > 0) { const row = rl.rows[0]; const windowAgeMinutes = (Date.now() - new Date(row.window_start).getTime()) / 60000; if (windowAgeMinutes < 10 && row.attempt_count >= 3) { return res.status(429).json({ error: '送信制限: 10分後に再試行してください' }); } if (windowAgeMinutes >= 10) { await pool.query( `UPDATE magic_link_rate_limit SET attempt_count = 1, window_start = NOW() WHERE email = $1`, [normalizedEmail] ); } else { await pool.query( `UPDATE magic_link_rate_limit SET attempt_count = attempt_count + 1 WHERE email = $1`, [normalizedEmail] ); } } else { await pool.query( `INSERT INTO magic_link_rate_limit (email) VALUES ($1)`, [normalizedEmail] ); } // Generate token const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min await pool.query( `INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ($1, $2, $3)`, [normalizedEmail, token, expiresAt] ); // Send email via Resend (if API key is set) if (process.env.RESEND_API_KEY) { const magicLinkUrl = `${MAGIC_LINK_BASE_URL}/auth/verify?token=${token}`; try { const emailRes = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.RESEND_API_KEY}` }, body: JSON.stringify({ from: 'Posimai ', to: [normalizedEmail], subject: 'Posimai ログインリンク', html: `

以下のリンクをクリックして Posimai にログインしてください。

${magicLinkUrl}

このリンクは15分間有効です。

このメールに心当たりがない場合は無視してください。

` }) }); if (!emailRes.ok) { const errBody = await emailRes.text(); console.error('[Auth] Resend API error:', emailRes.status, errBody); } else { console.log(`[Auth] Magic link sent to ${normalizedEmail}`); } } catch (emailErr) { console.error('[Auth] Email send failed:', emailErr.message); } } else { // Dev mode: log token to console console.log(`[Auth] Magic link token (dev): ${token}`); } res.json({ ok: true, message: 'ログインリンクを送信しました' }); } catch (e) { console.error('[Auth] Magic link send error:', e); res.status(500).json({ error: 'サーバーエラーが発生しました' }); } }); // GET /api/auth/magic-link/verify?token=xxx r.get('/auth/magic-link/verify', async (req, res) => { const { token } = req.query; if (!token) return res.status(400).json({ error: 'トークンが必要です' }); try { const result = await pool.query( `SELECT * FROM magic_link_tokens WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`, [token] ); if (result.rows.length === 0) { return res.status(401).json({ error: 'トークンが無効または期限切れです' }); } const { email } = result.rows[0]; // Mark token as used await pool.query( `UPDATE magic_link_tokens SET used_at = NOW() WHERE token = $1`, [token] ); // Find or create user by email let userResult = await pool.query( `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (userResult.rows.length > 0) { userId = userResult.rows[0].user_id; await pool.query( `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId] ); } else { // Generate user_id from email prefix const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); userId = `${baseId}_${Date.now().toString(36)}`; await pool.query( `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) ON CONFLICT (user_id) DO NOTHING`, [userId, baseId, email] ); } const sessionToken = await createSessionJWT(userId); res.json({ ok: true, token: sessionToken, userId }); } catch (e) { console.error('[Auth] Magic link verify error:', e); res.status(500).json({ error: 'サーバーエラーが発生しました' }); } }); // GET /api/auth/session/verify — check current JWT + plan r.get('/auth/session/verify', authMiddleware, async (req, res) => { if (req.authType === 'apikey') { return res.json({ ok: true, userId: req.userId, authType: req.authType, plan: 'premium', purchased: true }); } try { const result = await pool.query( `SELECT plan, purchased_at FROM users WHERE user_id = $1`, [req.userId] ); const plan = result.rows[0]?.plan || 'free'; const purchased = plan === 'premium'; res.json({ ok: true, userId: req.userId, authType: req.authType, plan, purchased }); } catch (e) { res.json({ ok: true, userId: req.userId, authType: req.authType, plan: 'free', purchased: false }); } }); // ── Auth: Google OAuth ─────────────────────────────────────────── const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || ''; const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || ''; const OAUTH_BASE_URL = process.env.MAGIC_LINK_BASE_URL || 'http://localhost:3000'; // GET /api/auth/oauth/google — redirect to Google r.get('/auth/oauth/google', (req, res) => { const state = crypto.randomBytes(16).toString('hex'); webauthnChallenges.set(`oauth:${state}`, { expiresAt: Date.now() + 10 * 60 * 1000 }); const params = new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/google/callback`, response_type: 'code', scope: 'openid email profile', access_type: 'offline', prompt: 'select_account', state, }); res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); }); // GET /api/auth/oauth/google/callback r.get('/auth/oauth/google/callback', async (req, res) => { const { code, state } = req.query; if (!code) return res.redirect(`${OAUTH_BASE_URL}/login?error=no_code`); if (!state || !webauthnChallenges.has(`oauth:${state}`)) { return res.redirect(`${OAUTH_BASE_URL}/login?error=invalid_state`); } webauthnChallenges.delete(`oauth:${state}`); try { // Exchange code for tokens const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/google/callback`, grant_type: 'authorization_code', }), }); const tokenData = await tokenRes.json(); if (!tokenData.access_token) throw new Error('No access token'); // Get user info const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); const userInfo = await userRes.json(); const email = userInfo.email?.toLowerCase(); if (!email) throw new Error('No email from Google'); // Find or create user const existing = await pool.query( `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (existing.rows.length > 0) { userId = existing.rows[0].user_id; await pool.query( `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId] ); } else { const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); userId = `${baseId}_${Date.now().toString(36)}`; await pool.query( `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) ON CONFLICT (user_id) DO NOTHING`, [userId, baseId, email] ); } const token = await createSessionJWT(userId); res.redirect(`${OAUTH_BASE_URL}/auth/verify?token=${token}&type=oauth`); } catch (e) { console.error('[OAuth Google]', e); res.redirect(`${OAUTH_BASE_URL}/login?error=google_failed`); } }); // ── Auth: GitHub OAuth ─────────────────────────────────────────── // GET /api/auth/oauth/github — redirect to GitHub r.get('/auth/oauth/github', (req, res) => { const state = crypto.randomBytes(16).toString('hex'); webauthnChallenges.set(`oauth:${state}`, { expiresAt: Date.now() + 10 * 60 * 1000 }); const params = new URLSearchParams({ client_id: GITHUB_CLIENT_ID, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/github/callback`, scope: 'user:email', state, }); res.redirect(`https://github.com/login/oauth/authorize?${params}`); }); // GET /api/auth/oauth/github/callback r.get('/auth/oauth/github/callback', async (req, res) => { const { code, state } = req.query; if (!code) return res.redirect(`${OAUTH_BASE_URL}/login?error=no_code`); if (!state || !webauthnChallenges.has(`oauth:${state}`)) { return res.redirect(`${OAUTH_BASE_URL}/login?error=invalid_state`); } webauthnChallenges.delete(`oauth:${state}`); try { // Exchange code for token const tokenRes = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET, code, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/github/callback`, }), }); const tokenData = await tokenRes.json(); if (!tokenData.access_token) throw new Error('No access token'); // Get user emails const emailRes = await fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${tokenData.access_token}`, 'User-Agent': 'Posimai' }, }); const emails = await emailRes.json(); const primary = emails.find((e) => e.primary && e.verified); const email = primary?.email?.toLowerCase(); if (!email) throw new Error('No verified email from GitHub'); // Find or create user const existing = await pool.query( `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (existing.rows.length > 0) { userId = existing.rows[0].user_id; await pool.query( `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId] ); } else { const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); userId = `${baseId}_${Date.now().toString(36)}`; await pool.query( `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) ON CONFLICT (user_id) DO NOTHING`, [userId, baseId, email] ); } const token = await createSessionJWT(userId); res.redirect(`${OAUTH_BASE_URL}/auth/verify?token=${token}&type=oauth`); } catch (e) { console.error('[OAuth GitHub]', e); res.redirect(`${OAUTH_BASE_URL}/login?error=github_failed`); } }); // DELETE /api/auth/session — logout (revoke session in DB) r.delete('/auth/session', authMiddleware, async (req, res) => { try { if (req.authType === 'session') { const auth = req.headers.authorization || ''; const token = auth.substring(7).trim(); try { const payload = jwt.decode(token); if (payload?.sid) { await pool.query( `DELETE FROM auth_sessions WHERE id = $1`, [payload.sid] ); } } catch (_) { /* ignore decode errors */ } } res.json({ ok: true }); } catch (e) { console.error('[Auth] Session delete error:', e); res.status(500).json({ error: 'サーバーエラーが発生しました' }); } }); // ── Auth: Passkey / WebAuthn ───────────────────────────────────── // POST /api/auth/passkey/register/begin (requires existing session) r.post('/auth/passkey/register/begin', authMiddleware, async (req, res) => { if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); const userId = req.userId; try { // Get or create stable user handle (random bytes, not user_id) let handleResult = await pool.query( `SELECT user_handle FROM webauthn_user_handles WHERE user_id = $1`, [userId] ); let userHandle; if (handleResult.rows.length > 0) { userHandle = handleResult.rows[0].user_handle; } else { userHandle = crypto.randomBytes(16).toString('base64url'); await pool.query( `INSERT INTO webauthn_user_handles (user_id, user_handle) VALUES ($1, $2) ON CONFLICT (user_id) DO NOTHING`, [userId, userHandle] ); } // Get existing credentials (to exclude from registration options) const existing = await pool.query( `SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $1`, [userId] ); const excludeCredentials = existing.rows.map(row => ({ id: row.credential_id, transports: row.transports || [] })); // Get user info for display name const userInfo = await pool.query(`SELECT name, email FROM users WHERE user_id = $1`, [userId]); const displayName = userInfo.rows[0]?.email || userId; const options = await webauthn.generateRegistrationOptions({ rpName: WEBAUTHN_RP_NAME, rpID: WEBAUTHN_RP_ID, userID: Buffer.from(userHandle), userName: userId, userDisplayName: displayName, attestationType: 'none', excludeCredentials, authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' } }); // Store challenge (5 min TTL) webauthnChallenges.set(`reg:${userId}`, { challenge: options.challenge, expiresAt: Date.now() + 5 * 60 * 1000 }); res.json(options); } catch (e) { console.error('[Auth] Passkey register begin error:', e); res.status(500).json({ error: 'パスキー登録の開始に失敗しました' }); } }); // POST /api/auth/passkey/register/finish (requires existing session) r.post('/auth/passkey/register/finish', authMiddleware, async (req, res) => { if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); const userId = req.userId; const challengeEntry = webauthnChallenges.get(`reg:${userId}`); if (!challengeEntry || challengeEntry.expiresAt < Date.now()) { return res.status(400).json({ error: '登録セッションが期限切れです。最初からやり直してください' }); } try { const verification = await webauthn.verifyRegistrationResponse({ response: req.body, expectedChallenge: challengeEntry.challenge, expectedOrigin: WEBAUTHN_ORIGINS, expectedRPID: WEBAUTHN_RP_ID }); if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: 'パスキーの検証に失敗しました' }); } webauthnChallenges.delete(`reg:${userId}`); const { credential, credentialDeviceType } = verification.registrationInfo; const displayName = req.body.displayName || req.headers['user-agent']?.substring(0, 50) || 'Unknown device'; await pool.query( `INSERT INTO passkey_credentials (user_id, credential_id, public_key, counter, device_type, transports, display_name) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (credential_id) DO UPDATE SET counter = $4, last_used_at = NOW()`, [ userId, credential.id, Buffer.from(credential.publicKey), credential.counter, credentialDeviceType, req.body.response?.transports || [], displayName ] ); res.json({ ok: true }); } catch (e) { console.error('[Auth] Passkey register finish error:', e); res.status(500).json({ error: 'パスキー登録に失敗しました' }); } }); // POST /api/auth/passkey/login/begin r.post('/auth/passkey/login/begin', async (req, res) => { if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); const { email } = req.body; // レート制限: IP ごとに 10回/分 const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; if (!checkRateLimit('passkey_login', ip, 10, 60 * 1000)) { return res.status(429).json({ error: '試行回数が多すぎます。しばらくしてからお試しください' }); } const challengeKey = email ? `login:${email.toLowerCase().trim()}` : `login:anon:${crypto.randomBytes(8).toString('hex')}`; try { let allowCredentials = []; if (email) { const normalizedEmail = email.toLowerCase().trim(); const userResult = await pool.query( `SELECT u.user_id FROM users u WHERE u.email = $1`, [normalizedEmail] ); if (userResult.rows.length > 0) { const userId = userResult.rows[0].user_id; const creds = await pool.query( `SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $1`, [userId] ); allowCredentials = creds.rows.map(row => ({ id: row.credential_id, transports: row.transports || [] })); } } const options = await webauthn.generateAuthenticationOptions({ rpID: WEBAUTHN_RP_ID, allowCredentials, userVerification: 'preferred' }); webauthnChallenges.set(challengeKey, { challenge: options.challenge, email: email ? email.toLowerCase().trim() : null, expiresAt: Date.now() + 5 * 60 * 1000 }); res.json({ ...options, _challengeKey: challengeKey }); } catch (e) { console.error('[Auth] Passkey login begin error:', e); res.status(500).json({ error: 'パスキーログインの開始に失敗しました' }); } }); // POST /api/auth/passkey/login/finish r.post('/auth/passkey/login/finish', async (req, res) => { if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); const { _challengeKey, ...assertionResponse } = req.body; if (!_challengeKey) return res.status(400).json({ error: 'challengeKey が必要です' }); const challengeEntry = webauthnChallenges.get(_challengeKey); if (!challengeEntry || challengeEntry.expiresAt < Date.now()) { return res.status(400).json({ error: 'ログインセッションが期限切れです。最初からやり直してください' }); } try { // Find credential in DB const credResult = await pool.query( `SELECT c.*, c.user_id FROM passkey_credentials c WHERE c.credential_id = $1`, [assertionResponse.id] ); if (credResult.rows.length === 0) { return res.status(401).json({ error: 'パスキーが見つかりません' }); } const cred = credResult.rows[0]; const verification = await webauthn.verifyAuthenticationResponse({ response: assertionResponse, expectedChallenge: challengeEntry.challenge, expectedOrigin: WEBAUTHN_ORIGINS, expectedRPID: WEBAUTHN_RP_ID, credential: { id: cred.credential_id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: cred.transports || [] } }); if (!verification.verified) { return res.status(401).json({ error: 'パスキーの検証に失敗しました' }); } webauthnChallenges.delete(_challengeKey); // Update counter and last_used_at await pool.query( `UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`, [verification.authenticationInfo.newCounter, cred.credential_id] ); const sessionToken = await createSessionJWT(cred.user_id); res.json({ ok: true, token: sessionToken, userId: cred.user_id }); } catch (e) { console.error('[Auth] Passkey login finish error:', e); res.status(500).json({ error: 'パスキーログインに失敗しました' }); } }); // GET /api/auth/passkeys — list user's registered passkeys r.get('/auth/passkeys', authMiddleware, async (req, res) => { try { const result = await pool.query( `SELECT id, credential_id, device_type, transports, display_name, created_at, last_used_at FROM passkey_credentials WHERE user_id = $1 ORDER BY created_at DESC`, [req.userId] ); res.json({ passkeys: result.rows }); } catch (e) { console.error('[Auth] List passkeys error:', e); res.status(500).json({ error: 'サーバーエラーが発生しました' }); } }); // DELETE /api/auth/passkeys/:id — remove a passkey r.delete('/auth/passkeys/:id', authMiddleware, async (req, res) => { try { const result = await pool.query( `DELETE FROM passkey_credentials WHERE id = $1 AND user_id = $2 RETURNING id`, [req.params.id, req.userId] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'パスキーが見つかりません' }); } res.json({ ok: true }); } catch (e) { console.error('[Auth] Delete passkey error:', e); res.status(500).json({ error: 'サーバーエラーが発生しました' }); } }); // 記事一覧取得 r.get('/articles', authMiddleware, async (req, res) => { const { status, topic, source, q } = req.query; let sql = `SELECT id, url, title, summary, topics, source, status, previous_status, reading_time, favicon, saved_at, (full_text IS NOT NULL) AS has_full_text FROM articles WHERE user_id = $1`; const params = [req.userId]; let i = 2; if (status) { sql += ` AND status = $${i++}`; params.push(status); } if (topic) { sql += ` AND $${i++} = ANY(topics)`; params.push(topic); } if (source) { sql += ` AND source ILIKE $${i++}`; params.push(source); } if (q) { sql += ` AND (title ILIKE $${i} OR summary ILIKE $${i})`; params.push(`%${q}%`); i++; } sql += ' ORDER BY saved_at DESC LIMIT 300'; try { const { rows } = await pool.query(sql, params); // カウント計算 const counts = { all: rows.length, unread: rows.filter(a => a.status === 'inbox').length, favorite: rows.filter(a => a.status === 'favorite').length, shared: rows.filter(a => a.status === 'shared').length }; res.json({ articles: rows, counts }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // 記事詳細取得(full_text を含む完全版) r.get('/articles/:id', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( 'SELECT * FROM articles WHERE id=$1 AND user_id=$2', [req.params.id, req.userId] ); if (rows.length === 0) return res.status(404).json({ error: 'Article not found' }); return res.json({ article: rows[0] }); } catch (e) { console.error('[Brain API] GET /articles/:id failed:', e.stack || e); return res.status(500).json({ error: 'DB error' }); } }); // ========== 記事保存(Jina Reader自動取得対応)========== r.post('/save', authMiddleware, async (req, res) => { const { url, title: clientTitle, content, source: clientSource } = req.body || {}; if (!url) return res.status(400).json({ error: 'url is required' }); let parsedUrl; try { parsedUrl = new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); } if (!['http:', 'https:'].includes(parsedUrl.protocol)) return res.status(400).json({ error: 'Only http/https' }); try { const meta = await fetchMeta(url); let fullText = content || null; const source = clientSource || extractSource(url); // 重要: contentが空の場合、Jina Reader APIで本文を自動取得 if (!fullText || fullText.trim().length === 0) { console.log(`[Brain API] No content provided for ${url}, attempting Jina Reader fetch...`); const jinaText = await fetchFullTextViaJina(url); if (jinaText && jinaText.length > 0) { fullText = jinaText; console.log(`[Brain API] ✓ Using Jina Reader full text (${fullText.length} chars)`); } else { // Jina Reader失敗時はOGP descriptionをフォールバック console.log(`[Brain API] ⚠ Jina Reader failed, falling back to OGP description`); fullText = meta.desc || ''; } } else { console.log(`[Brain API] Using provided content (${fullText.length} chars)`); } // 即座に保存してフロントに返す(AIはバックグラウンド) let articleQuery = await pool.query(` INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' RETURNING * `, [req.userId, url, clientTitle || meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); let article = articleQuery.rows[0]; res.json({ ok: true, article, aiStatus: 'pending' }); // バックグラウンドでAI処理(ユーザーごとに 50記事/時間 まで) if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) { analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => { await pool.query(` UPDATE articles SET summary=$1, topics=$2, reading_time=$3 WHERE user_id=$4 AND url=$5 `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); console.log(`[Brain API] ✓ AI analysis completed for ${url}`); }).catch(e => console.error('[Background AI Error]:', e)); } } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' }); console.error(e); res.status(500).json({ error: 'DB error' }); } }); // ステータス更新 r.patch('/articles/:id/status', authMiddleware, async (req, res) => { const { status } = req.body || {}; const valid = ['inbox', 'favorite', 'shared']; if (!valid.includes(status)) return res.status(400).json({ error: 'Invalid status' }); try { const readAt = status === 'shared' || status === 'reading' ? 'NOW()' : 'NULL'; if (status === 'favorite') { await pool.query( `UPDATE articles SET previous_status=status, status=$1, read_at=${readAt === 'NULL' ? 'NULL' : 'NOW()'} WHERE id=$2 AND user_id=$3`, [status, req.params.id, req.userId] ); } else { await pool.query( `UPDATE articles SET status=$1, read_at=${readAt === 'NULL' ? 'NULL' : 'NOW()'} WHERE id=$2 AND user_id=$3`, [status, req.params.id, req.userId] ); } res.json({ ok: true }); } catch (e) { res.status(500).json({ error: 'DB error' }); } }); // 削除 r.delete('/articles/:id', authMiddleware, async (req, res) => { try { await pool.query('DELETE FROM articles WHERE id=$1 AND user_id=$2', [req.params.id, req.userId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: 'DB error' }); } }); // クイック保存 (Bookmarklet等からのGET) — Jina Reader対応 r.get('/quick-save', authMiddleware, async (req, res) => { const url = req.query.url; if (!url) return res.status(400).send('

URL not provided

'); try { const meta = await fetchMeta(url); const source = extractSource(url); // Jina Readerで本文取得を試みる let fullText = await fetchFullTextViaJina(url); if (!fullText || fullText.length === 0) { fullText = meta.desc || ''; } await pool.query(` INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' `, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); // バックグラウンドAI(ユーザーごとに 50記事/時間 まで) if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) { analyzeWithGemini(meta.title, fullText, url).then(async (ai) => { await pool.query(` UPDATE articles SET summary=$1, topics=$2, reading_time=$3 WHERE user_id=$4 AND url=$5 `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); }).catch(e => console.error('[Background AI Error]:', e)); } // HTMLレスポンス(自動で閉じる) res.send(` 保存完了

✓ 保存しました

${escapeHtml(meta.title)}

AI分析をバックグラウンドで開始しました

`); } catch (e) { res.status(500).send(`

保存失敗: ${escapeHtml(e.message)}

`); } }); // ========== 履歴機能 ========== // POST /api/history/save - 軽量履歴保存(AI分析なし) r.post('/history/save', authMiddleware, async (req, res) => { const { url, title } = req.body || {}; if (!url) return res.status(400).json({ error: 'url is required' }); try { const domain = new URL(url).hostname.replace(/^www\./, ''); await pool.query(` INSERT INTO reading_history (user_id, url, title, domain, read_at) VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (user_id, url) DO UPDATE SET title = EXCLUDED.title, read_at = NOW() `, [req.userId, url, title || '', domain]); res.json({ ok: true, message: '履歴に保存しました' }); } catch (e) { console.error('[History Save Error]:', e); res.status(500).json({ error: 'Failed to save history' }); } }); // GET /api/history - 履歴取得 r.get('/history', authMiddleware, async (req, res) => { const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100); try { const result = await pool.query(` SELECT url, title, domain, read_at FROM reading_history WHERE user_id = $1 ORDER BY read_at DESC LIMIT $2 `, [req.userId, limit]); res.json({ ok: true, history: result.rows, count: result.rows.length }); } catch (e) { console.error('[History Fetch Error]:', e); res.status(500).json({ error: 'Failed to fetch history' }); } }); // ── Journal API ────────────────────────── // GET /journal/posts/public — 認証不要・published=true のみ(posimai-site 用) // ?user=maita でユーザー指定可能(将来の独立サイト対応) r.get('/journal/posts/public', async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100); const userId = req.query.user || null; const { rows } = userId ? await pool.query( `SELECT id, title, body, tags, created_at, updated_at FROM journal_posts WHERE published=TRUE AND user_id=$1 ORDER BY updated_at DESC LIMIT $2`, [userId, limit] ) : await pool.query( `SELECT id, title, body, tags, created_at, updated_at FROM journal_posts WHERE published=TRUE ORDER BY updated_at DESC LIMIT $1`, [limit] ); res.json({ posts: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // GET /journal/posts — 記事一覧(認証あり・全記事) r.get('/journal/posts', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( 'SELECT id, title, body, tags, published, created_at, updated_at FROM journal_posts WHERE user_id=$1 ORDER BY updated_at DESC', [req.userId] ); res.json({ posts: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /journal/posts — 新規作成 or 更新(id があれば更新) r.post('/journal/posts', authMiddleware, async (req, res) => { const { id, title, body, tags, published } = req.body || {}; const tagArr = Array.isArray(tags) ? tags.map(String) : []; const pub = published === true || published === 'true'; try { if (id) { const { rows } = await pool.query( `UPDATE journal_posts SET title=$1, body=$2, tags=$3, published=$4, updated_at=NOW() WHERE id=$5 AND user_id=$6 RETURNING id, title, body, tags, published, created_at, updated_at`, [title || '', body || '', tagArr, pub, id, req.userId] ); if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); res.json({ post: rows[0] }); } else { const { rows } = await pool.query( `INSERT INTO journal_posts (user_id, title, body, tags, published) VALUES ($1,$2,$3,$4,$5) RETURNING id, title, body, tags, published, created_at, updated_at`, [req.userId, title || '', body || '', tagArr, pub] ); res.json({ post: rows[0] }); } } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // DELETE /journal/posts/:id — 削除 r.delete('/journal/posts/:id', authMiddleware, async (req, res) => { try { await pool.query( 'DELETE FROM journal_posts WHERE id=$1 AND user_id=$2', [req.params.id, req.userId] ); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // ── Site Config API ─────────────────────── // GET /site/config/public — 認証不要・posimai-site 用 // ?user=maita でユーザー指定可能(将来の独立サイト対応) r.get('/site/config/public', async (req, res) => { try { const userId = req.query.user || 'maita'; const { rows } = await pool.query( 'SELECT key, value FROM site_config WHERE user_id=$1', [userId] ); const config = {}; rows.forEach(row => { config[row.key] = row.value; }); res.json({ config }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /site/config/:key — 認証あり・設定を保存(ユーザー別) r.post('/site/config/:key', authMiddleware, async (req, res) => { const { key } = req.params; const { value } = req.body; if (!key || value === undefined) return res.status(400).json({ error: 'value required' }); try { await pool.query( `INSERT INTO site_config (user_id, key, value, updated_at) VALUES ($1, $2, $3::jsonb, NOW()) ON CONFLICT (user_id, key) DO UPDATE SET value=$3::jsonb, updated_at=NOW()`, [req.userId, key, JSON.stringify(value)] ); res.json({ ok: true, key }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /journal/upload — 画像アップロード(base64、認証あり) // POST /journal/suggest-tags — Gemini でタグ候補を提案 r.post('/journal/suggest-tags', authMiddleware, async (req, res) => { if (!genAI) return res.status(503).json({ error: 'Gemini not configured' }); // レート制限: ユーザーごとに 10回/時間 if (!checkRateLimit('gemini_suggest_tags', req.userId, 10, 60 * 60 * 1000)) { return res.status(429).json({ error: 'AI提案の利用回数が上限に達しました。1時間後に再試行してください' }); } const { title = '', body = '' } = req.body || {}; if (!title && !body) return res.status(400).json({ error: 'title or body required' }); try { const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-lite', generationConfig: { responseMimeType: 'application/json' } }); const excerpt = smartExtract(body, 2000); const prompt = `以下の記事にふさわしい日本語タグを3〜5個提案してください。 タグは短い単語または短いフレーズ(例: "Next.js", "インフラ", "開発メモ", "Tailscale")。 既存のタグと重複してもOK。結果はJSONで返してください。 タイトル: ${title.slice(0, 80)} 本文抜粋: ${excerpt} {"tags":["タグ1","タグ2","タグ3"]}`; const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 12000) ); const result = await Promise.race([model.generateContent(prompt), timeoutPromise]); let raw = result.response.text().trim() .replace(/^```(json)?/i, '').replace(/```$/i, '').trim(); const parsed = JSON.parse(raw); const tags = (parsed.tags || []).filter(t => typeof t === 'string' && t.length <= 30).slice(0, 5); res.json({ tags }); } catch (e) { console.error('[suggest-tags]', e.message); res.status(500).json({ error: 'AI suggestion failed' }); } }); r.post('/journal/upload', authMiddleware, (req, res) => { try { const { base64 } = req.body || {}; if (!base64) return res.status(400).json({ error: 'base64 required' }); const match = base64.match(/^data:(image\/(jpeg|png|gif|webp));base64,(.+)$/); if (!match) return res.status(400).json({ error: 'Invalid image format' }); const ext = match[2] === 'jpeg' ? 'jpg' : match[2]; const buffer = Buffer.from(match[3], 'base64'); if (buffer.length > 5 * 1024 * 1024) { return res.status(400).json({ error: 'File too large (max 5MB)' }); } const name = crypto.randomBytes(10).toString('hex') + '.' + ext; fs.writeFileSync(path.join(UPLOADS_DIR, name), buffer); const url = `https://api.soar-enrich.com/brain/api/uploads/${name}`; res.json({ ok: true, url }); } catch (e) { console.error('[Upload Error]:', e); res.status(500).json({ error: 'Upload failed' }); } }); // ── Habit API ───────────────────────────── // GET /habit/habits — habit 一覧取得 r.get('/habit/habits', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( 'SELECT id, name, icon, position FROM habit_habits WHERE user_id=$1 ORDER BY position, id', [req.userId] ); res.json({ habits: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /habit/habits — habit 作成 r.post('/habit/habits', authMiddleware, async (req, res) => { const { name, icon = 'check', position = 0 } = req.body || {}; if (!name) return res.status(400).json({ error: 'name required' }); try { const { rows } = await pool.query( 'INSERT INTO habit_habits (user_id, name, icon, position) VALUES ($1,$2,$3,$4) RETURNING id, name, icon, position', [req.userId, name, icon, position] ); res.json({ habit: rows[0] }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // PATCH /habit/habits/:id — habit 更新(name / icon / position) r.patch('/habit/habits/:id', authMiddleware, async (req, res) => { const { name, icon, position } = req.body || {}; try { const sets = []; const params = []; let i = 1; if (name !== undefined) { sets.push(`name=$${i++}`); params.push(name); } if (icon !== undefined) { sets.push(`icon=$${i++}`); params.push(icon); } if (position !== undefined) { sets.push(`position=$${i++}`); params.push(position); } if (sets.length === 0) return res.status(400).json({ error: 'nothing to update' }); params.push(req.params.id, req.userId); const { rows } = await pool.query( `UPDATE habit_habits SET ${sets.join(',')} WHERE id=$${i++} AND user_id=$${i} RETURNING id, name, icon, position`, params ); if (rows.length === 0) return res.status(404).json({ error: 'Not found' }); res.json({ habit: rows[0] }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // DELETE /habit/habits/:id — habit 削除 r.delete('/habit/habits/:id', authMiddleware, async (req, res) => { try { await pool.query( 'DELETE FROM habit_habits WHERE id=$1 AND user_id=$2', [req.params.id, req.userId] ); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // GET /habit/log/:date — その日のチェック済み habit_id 一覧 r.get('/habit/log/:date', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( 'SELECT habit_id FROM habit_log WHERE user_id=$1 AND log_date=$2', [req.userId, req.params.date] ); res.json({ checked: rows.map(r => r.habit_id) }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /habit/log/:date — habit をトグル(checked=true で追加、false で削除) r.post('/habit/log/:date', authMiddleware, async (req, res) => { const { habitId, checked } = req.body || {}; if (!habitId) return res.status(400).json({ error: 'habitId required' }); try { if (checked) { await pool.query( 'INSERT INTO habit_log (user_id, habit_id, log_date) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', [req.userId, habitId, req.params.date] ); } else { await pool.query( 'DELETE FROM habit_log WHERE user_id=$1 AND habit_id=$2 AND log_date=$3', [req.userId, habitId, req.params.date] ); } res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // GET /habit/heatmap — 過去 N 日分のチェック数(ヒートマップ用) r.get('/habit/heatmap', authMiddleware, async (req, res) => { const days = Math.min(parseInt(req.query.days || '90') || 90, 365); try { const { rows } = await pool.query(` SELECT log_date::text AS date, COUNT(*) AS count FROM habit_log WHERE user_id=$1 AND log_date >= CURRENT_DATE - ($2 || ' days')::INTERVAL GROUP BY log_date ORDER BY log_date `, [req.userId, days]); res.json({ heatmap: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // ── Pulse API ───────────────────────────── // GET /pulse/log/:date — 特定日のデータ取得 r.get('/pulse/log/:date', authMiddleware, async (req, res) => { try { const { rows } = await pool.query( 'SELECT mood, energy, focus, note FROM pulse_log WHERE user_id=$1 AND log_date=$2', [req.userId, req.params.date] ); res.json({ entry: rows[0] || null }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /pulse/log/:date — 記録(UPSERT) r.post('/pulse/log/:date', authMiddleware, async (req, res) => { const { mood, energy, focus, note = '' } = req.body || {}; if (!mood && !energy && !focus) return res.status(400).json({ error: 'at least one metric required' }); try { const { rows } = await pool.query(` INSERT INTO pulse_log (user_id, log_date, mood, energy, focus, note, updated_at) VALUES ($1,$2,$3,$4,$5,$6,NOW()) ON CONFLICT (user_id, log_date) DO UPDATE SET mood=COALESCE($3, pulse_log.mood), energy=COALESCE($4, pulse_log.energy), focus=COALESCE($5, pulse_log.focus), note=COALESCE(NULLIF($6,''), pulse_log.note), updated_at=NOW() RETURNING mood, energy, focus, note `, [req.userId, req.params.date, mood || null, energy || null, focus || null, note]); res.json({ entry: rows[0] }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // GET /pulse/log — 範囲取得(デフォルト直近30日) r.get('/pulse/log', authMiddleware, async (req, res) => { const days = Math.min(parseInt(req.query.days || '30') || 30, 365); try { const { rows } = await pool.query(` SELECT log_date::text AS date, mood, energy, focus, note FROM pulse_log WHERE user_id=$1 AND log_date >= CURRENT_DATE - ($2 || ' days')::INTERVAL ORDER BY log_date `, [req.userId, days]); res.json({ entries: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // ── Lens API ────────────────────────────── // GET /lens/history — スキャン履歴取得(直近 limit 件) r.get('/lens/history', authMiddleware, async (req, res) => { const limit = Math.min(parseInt(req.query.limit || '20') || 20, 100); try { const { rows } = await pool.query( 'SELECT id, filename, exif_data, thumbnail, scanned_at FROM lens_history WHERE user_id=$1 ORDER BY scanned_at DESC LIMIT $2', [req.userId, limit] ); res.json({ history: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // POST /lens/history — スキャン結果を保存 r.post('/lens/history', authMiddleware, async (req, res) => { const { filename, exif_data, thumbnail } = req.body || {}; if (!exif_data) return res.status(400).json({ error: 'exif_data required' }); try { const { rows } = await pool.query( 'INSERT INTO lens_history (user_id, filename, exif_data, thumbnail) VALUES ($1,$2,$3,$4) RETURNING id, scanned_at', [req.userId, filename || null, JSON.stringify(exif_data), thumbnail || null] ); res.json({ ok: true, id: rows[0].id, scanned_at: rows[0].scanned_at }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // DELETE /lens/history/:id — 履歴削除 r.delete('/lens/history/:id', authMiddleware, async (req, res) => { try { await pool.query( 'DELETE FROM lens_history WHERE id=$1 AND user_id=$2', [req.params.id, req.userId] ); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // ── Feed Media(カスタムRSSソース管理)──────────────────────────── // テーブル: feed_media (id SERIAL PK, user_id TEXT, name TEXT, feed_url TEXT, // site_url TEXT, category TEXT, is_active BOOLEAN DEFAULT true, // created_at TIMESTAMPTZ DEFAULT NOW()) r.get('/feed/media', authMiddleware, async (req, res) => { try { // default_user のメディア(共通)+ ユーザー自身のメディアを統合して返す const result = await pool.query( `SELECT id, name, feed_url, site_url, category, is_active, created_at, (user_id = 'default_user') AS is_default FROM feed_media WHERE user_id = 'default_user' OR user_id = $1 ORDER BY is_default DESC, created_at ASC`, [req.userId] ); res.json(result.rows); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); r.post('/feed/media', authMiddleware, async (req, res) => { const { name, feed_url, site_url = '', is_active = true } = req.body || {}; if (!name || !feed_url) return res.status(400).json({ error: 'name and feed_url required' }); // カテゴリ自動判定(指定があればそのまま使用) let category = req.body.category || ''; if (!category) { const urlLower = feed_url.toLowerCase(); if (/news|nhk|yahoo|nikkei|asahi|mainichi|yomiuri/.test(urlLower)) category = 'news'; else if (/business|bizjapan|diamond|toyo|kaizen/.test(urlLower)) category = 'business'; else if (/lifestyle|life|food|cooking|fashion|beauty|travel/.test(urlLower)) category = 'lifestyle'; else category = 'tech'; } try { const result = await pool.query( 'INSERT INTO feed_media (user_id, name, feed_url, site_url, category, is_active) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, name, feed_url, site_url, category, is_active, created_at', [req.userId, name, feed_url, site_url, category, is_active] ); res.status(201).json(result.rows[0]); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'このメディアはすでに追加済みです' }); console.error(e); res.status(500).json({ error: 'DB error' }); } }); r.patch('/feed/media/:id', authMiddleware, async (req, res) => { const { name, feed_url, site_url, category, is_active } = req.body || {}; try { const fields = []; const vals = []; let idx = 1; if (name !== undefined) { fields.push(`name=$${idx++}`); vals.push(name); } if (feed_url !== undefined) { fields.push(`feed_url=$${idx++}`); vals.push(feed_url); } if (site_url !== undefined) { fields.push(`site_url=$${idx++}`); vals.push(site_url); } if (category !== undefined) { fields.push(`category=$${idx++}`); vals.push(category); } if (is_active !== undefined) { fields.push(`is_active=$${idx++}`); vals.push(is_active); } if (fields.length === 0) return res.status(400).json({ error: 'no fields to update' }); vals.push(req.params.id, req.userId); const result = await pool.query( `UPDATE feed_media SET ${fields.join(',')} WHERE id=$${idx} AND user_id=$${idx + 1} RETURNING id, name, feed_url, site_url, category, is_active`, vals ); if (result.rowCount === 0) return res.status(404).json({ error: 'not found' }); res.json(result.rows[0]); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); r.delete('/feed/media/:id', authMiddleware, async (req, res) => { try { await pool.query( 'DELETE FROM feed_media WHERE id=$1 AND user_id=$2', [req.params.id, req.userId] ); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // ── Feed Articles(DBキャッシュから高速配信)────────────────────── // VPS背景取得ジョブがfeed_articlesテーブルに書き込み、ここで読む r.get('/feed/articles', authMiddleware, async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit || '100'), 200); const offset = parseInt(req.query.offset || '0'); const category = req.query.category || null; const mediaId = req.query.media_id || null; let where = `WHERE fa.user_id IN ('default_user', $1)`; const vals = [req.userId]; let idx = 2; if (category) { where += ` AND fm.category = $${idx++}`; vals.push(category); } if (mediaId) { where += ` AND fa.media_id = $${idx++}`; vals.push(mediaId); } const [articlesResult, mediasResult] = await Promise.all([ pool.query( `SELECT fa.id, fa.url, fa.title, fa.summary, fa.author, fa.published_at, fa.is_read, fm.id AS media_id, fm.name AS source, fm.category, fm.feed_url, fm.site_url, fm.favicon FROM feed_articles fa JOIN feed_media fm ON fa.media_id = fm.id ${where} ORDER BY fa.published_at DESC NULLS LAST LIMIT $${idx} OFFSET $${idx + 1}`, [...vals, limit, offset] ), pool.query( `SELECT id, name, feed_url, site_url, category, favicon, (user_id = 'default_user') AS is_default FROM feed_media WHERE user_id IN ('default_user', $1) AND is_active = true ORDER BY is_default DESC, created_at ASC`, [req.userId] ) ]); res.json({ success: true, articles: articlesResult.rows, medias: mediasResult.rows, categories: [ { id: 'all', name: '全て', icon: 'layout-grid' }, { id: 'news', name: 'ニュース', icon: 'newspaper' }, { id: 'tech', name: 'テクノロジー', icon: 'rocket' }, { id: 'lifestyle', name: 'ライフスタイル', icon: 'coffee' }, { id: 'business', name: 'ビジネス', icon: 'briefcase' }, ], total: articlesResult.rows.length, updatedAt: new Date().toISOString() }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // Feed既読マーク r.patch('/feed/articles/:id/read', authMiddleware, async (req, res) => { try { await pool.query( `UPDATE feed_articles SET is_read = true WHERE id = $1 AND user_id IN ('default_user', $2)`, [req.params.id, req.userId] ); res.json({ ok: true }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); // 手動リフレッシュ(フロントの更新ボタンから呼ぶ) r.post('/feed/refresh', authMiddleware, async (req, res) => { try { const count = await runFeedFetch(); res.json({ ok: true, fetched: count }); } catch (e) { console.error(e); res.status(500).json({ error: 'Internal server error' }); } }); // ── TTS (VOICEVOX) ───────────────────────────────────────────── // VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021) // 未設定時は localhost フォールバック(NAS上で同じコンテナホストの場合) const VOICEVOX_URL = process.env.VOICEVOX_URL || 'http://voicevox:50021'; const ttsCache = new Map(); // key: "speaker:text" → Buffer const TTS_CACHE_MAX = 60; // 約60文を最大キャッシュ(メモリ節約) let ttsBusy = false; // VOICEVOX は同時1リクエストのみ対応(排他ロック) let preWarmBusy = false; // プリウォームが合成中(ユーザーリクエストを優先するために分離) let userWaiting = false; // ユーザーリクエスト待機中 → プリウォームをスキップ // 合成ヘルパー(/tts と /tts/warmup から共用) async function ttsSynthesize(text, speaker) { const queryRes = await fetch( `${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`, { method: 'POST' } ); if (!queryRes.ok) throw new Error(`VOICEVOX audio_query failed: ${queryRes.status}`); const query = await queryRes.json(); query.speedScale = 1.08; query.intonationScale = 1.15; query.prePhonemeLength = 0.05; query.postPhonemeLength = 0.1; const synthRes = await fetch(`${VOICEVOX_URL}/synthesis?speaker=${speaker}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }); if (!synthRes.ok) throw new Error(`VOICEVOX synthesis failed: ${synthRes.status}`); return Buffer.from(await synthRes.arrayBuffer()); } // POST /tts — テキストを音声(WAV)に変換して返す r.post('/tts', authMiddleware, async (req, res) => { const { text, speaker = 1 } = req.body || {}; if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' }); if (text.length > 600) return res.status(400).json({ error: 'text too long (max 600 chars)' }); const cacheKey = `${speaker}:${text}`; if (ttsCache.has(cacheKey)) { const cached = ttsCache.get(cacheKey); res.setHeader('Content-Type', 'audio/wav'); res.setHeader('Content-Length', cached.length); res.setHeader('X-TTS-Cache', 'HIT'); return res.send(cached); } // ユーザー待機フラグを立てる → プリウォームが残り記事をスキップして ttsBusy を解放 userWaiting = true; const deadline = Date.now() + 30000; while (ttsBusy && Date.now() < deadline) { await new Promise(r => setTimeout(r, 200)); } userWaiting = false; if (ttsBusy) return res.status(503).json({ error: 'TTS_BUSY' }); try { ttsBusy = true; const audioBuffer = await ttsSynthesize(text, speaker); if (ttsCache.size >= TTS_CACHE_MAX) { ttsCache.delete(ttsCache.keys().next().value); } ttsCache.set(cacheKey, audioBuffer); res.setHeader('Content-Type', 'audio/wav'); res.setHeader('Content-Length', audioBuffer.length); res.setHeader('X-TTS-Cache', 'MISS'); res.send(audioBuffer); } catch (e) { console.error('[TTS]', e.message); res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message }); } finally { ttsBusy = false; } }); // POST /tts/ready — 指定テキストがキャッシュ済みか確認(ポーリング用) r.post('/tts/ready', authMiddleware, (req, res) => { const { texts, speaker = 1 } = req.body || {}; if (!texts || !Array.isArray(texts)) return res.json({ cached: 0, total: 0, ready: false }); const total = texts.length; const cached = texts.filter(t => ttsCache.has(`${speaker}:${t}`)).length; res.json({ cached, total, ready: cached === total }); }); // POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める // ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。 r.post('/tts/warmup', authMiddleware, async (req, res) => { const { texts, speaker = 1 } = req.body || {}; if (!texts || !Array.isArray(texts) || texts.length === 0) { return res.status(400).json({ error: 'texts[] required' }); } const valid = texts.filter(t => typeof t === 'string' && t.length > 0 && t.length <= 600); res.status(202).json({ queued: valid.length }); // 即座に返す(合成は行わない) }); // GET /tts/speakers — 利用可能な話者一覧(デバッグ用) r.get('/tts/speakers', authMiddleware, async (req, res) => { try { const r2 = await fetch(`${VOICEVOX_URL}/speakers`); const speakers = await r2.json(); res.json(speakers); } catch (e) { res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message }); } }); // Brief クライアントと同じ前処理(キャッシュキーを一致させるため) function preWarmPreprocess(t) { return (t || '') .replace(/https?:\/\/\S+/g, '') .replace(/[「」『』【】〔〕《》]/g, '') .replace(/([。!?])([^\s])/g, '$1 $2') .replace(/\s{2,}/g, ' ') .trim(); } // ── サーバー側自動プリウォーム ────────────────────────────────── // 起動時 + 30分ごとに最新フィード記事の音声を VOICEVOX で合成してキャッシュ // ユーザーが開いたときは既にキャッシュ済み → 即再生 async function preWarmFeedAudio() { try { console.log('[TTS pre-warm] fetching feed...'); const feedRes = await fetch('https://posimai-feed.vercel.app/api/feed', { signal: AbortSignal.timeout(15000) }); if (!feedRes.ok) throw new Error(`feed ${feedRes.status}`); const data = await feedRes.json(); const articles = (data.articles || []).slice(0, 5); if (articles.length === 0) return; // ブラウザと同じロジックでテキスト生成(Brief の speechQueue + preprocessText と完全一致させる) const texts = []; articles.forEach((a, i) => { const prefix = i === 0 ? '最初のニュースです。' : '続いて。'; texts.push(preWarmPreprocess(`${prefix}${a.source || ''}より。${a.title || ''}`)); }); texts.push(preWarmPreprocess('本日のブリーフィングは以上です。')); const speaker = 1; // デフォルト: ずんだもん(明るい) for (const text of texts) { const cacheKey = `${speaker}:${text}`; if (ttsCache.has(cacheKey)) { console.log(`[TTS pre-warm] skip (cached): ${text.substring(0, 25)}`); continue; } if (ttsBusy || userWaiting) { console.log(`[TTS pre-warm] skip (user waiting): ${text.substring(0, 25)}`); continue; } ttsBusy = true; try { const buf = await ttsSynthesize(text, speaker); if (ttsCache.size >= TTS_CACHE_MAX) ttsCache.delete(ttsCache.keys().next().value); ttsCache.set(cacheKey, buf); console.log(`[TTS pre-warm] OK: ${text.substring(0, 25)}`); } catch (e) { console.error(`[TTS pre-warm] synth failed: ${e.message}`); } finally { ttsBusy = false; } } console.log('[TTS pre-warm] done'); } catch (e) { console.error('[TTS pre-warm] error:', e.message); } } // 起動直後に実行 + 30分ごとに更新(記事が変わっても自動対応) preWarmFeedAudio(); setInterval(preWarmFeedAudio, 30 * 60 * 1000); // ── IT イベント情報(Doorkeeper + connpass RSS) ────────────────────────── r.get('/events/rss', async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); try { const [doorkeeper, connpassEvents] = await Promise.allSettled([ fetchDoorkeeper(), fetchConnpassRss(), ]); const events = [ ...(doorkeeper.status === 'fulfilled' ? doorkeeper.value : []), ...(connpassEvents.status === 'fulfilled' ? connpassEvents.value : []), ]; // URLで重複排除 const seen = new Set(); const unique = events.filter(ev => { if (seen.has(ev.url)) return false; seen.add(ev.url); return true; }); // 開始日時順にソート unique.sort((a, b) => (a.startDate + a.startTime).localeCompare(b.startDate + b.startTime)); res.json({ events: unique, fetched_at: new Date().toISOString() }); } catch (err) { console.error('[events/rss]', err); res.status(500).json({ events: [], error: err.message }); } }); // ── Together ────────────────────────────────────────────────────────────── // invite_code 生成(8文字大文字16進) function genInviteCode() { return crypto.randomBytes(4).toString('hex').toUpperCase(); } // fire-and-forget アーカイブ: Jina Reader → Gemini 要約(直列) async function archiveShare(shareId, url) { if (!url) { await pool.query(`UPDATE together_shares SET archive_status='failed' WHERE id=$1`, [shareId]); return; } try { const jinaRes = await fetch(`https://r.jina.ai/${url}`, { headers: { 'Accept': 'text/plain', 'User-Agent': 'Posimai/1.0' }, signal: AbortSignal.timeout(30000), }); if (!jinaRes.ok) throw new Error(`Jina ${jinaRes.status}`); const fullContent = await jinaRes.text(); // Jina Reader のレスポンス先頭から "Title: ..." を抽出 const titleMatch = fullContent.match(/^Title:\s*(.+)/m); const jinaTitle = titleMatch ? titleMatch[1].trim().slice(0, 300) : null; await pool.query( `UPDATE together_shares SET full_content=$1, title=COALESCE(title, $2) WHERE id=$3`, [fullContent, jinaTitle, shareId] ); let summary = null; let tags = []; if (genAI && fullContent) { // 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す const bodyStart = fullContent.search(/^#{1,2}\s/m); const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000); const model = genAITogether.getGenerativeModel({ model: 'gemini-2.5-flash' }); const prompt = `以下の記事を分析して、JSONのみを返してください(コードブロック不要)。\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと。内容が不明な場合でも最も近いカテゴリを選ぶ(例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン, ライフスタイル, 教育, 環境, 医療, 法律, 文化, 歴史)\n\n記事:\n${excerpt}`; const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000)); const result = await Promise.race([model.generateContent(prompt), timeoutP]); const raw = result.response.text().trim(); try { const parsed = JSON.parse(raw); summary = (parsed.summary || '').slice(0, 300); tags = Array.isArray(parsed.tags) ? parsed.tags.slice(0, 4).map(t => String(t).slice(0, 20)) : []; } catch { // JSON パース失敗時は全文を要約として扱う summary = raw.slice(0, 300); } } await pool.query( `UPDATE together_shares SET summary=$1, tags=$2, archive_status='done' WHERE id=$3`, [summary, tags, shareId] ); } catch (e) { console.error('[together archive]', shareId, e.message); await pool.query(`UPDATE together_shares SET archive_status='failed' WHERE id=$1`, [shareId]); } } // POST /together/groups — グループ作成 r.post('/together/groups', async (req, res) => { const { name, username } = req.body || {}; if (!name || !username) return res.status(400).json({ error: 'name と username は必須です' }); try { let invite_code, attempt = 0; while (attempt < 5) { invite_code = genInviteCode(); const exists = await pool.query('SELECT id FROM together_groups WHERE invite_code=$1', [invite_code]); if (exists.rows.length === 0) break; attempt++; } const group = await pool.query( `INSERT INTO together_groups (name, invite_code) VALUES ($1, $2) RETURNING *`, [name, invite_code] ); await pool.query( `INSERT INTO together_members (group_id, username) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [group.rows[0].id, username] ); res.json({ group: group.rows[0] }); } catch (e) { console.error('[together/groups POST]', e.message); res.status(500).json({ error: 'グループ作成に失敗しました' }); } }); // POST /together/join — 招待コードでグループ参加 r.post('/together/join', async (req, res) => { const { invite_code, username } = req.body || {}; if (!invite_code || !username) return res.status(400).json({ error: 'invite_code と username は必須です' }); try { const group = await pool.query( 'SELECT * FROM together_groups WHERE invite_code=$1', [invite_code.toUpperCase()] ); if (group.rows.length === 0) return res.status(404).json({ error: '招待コードが見つかりません' }); await pool.query( `INSERT INTO together_members (group_id, username) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [group.rows[0].id, username] ); res.json({ group: group.rows[0] }); } catch (e) { console.error('[together/join]', e.message); res.status(500).json({ error: 'グループ参加に失敗しました' }); } }); // GET /together/groups/:groupId — グループ情報 r.get('/together/groups/:groupId', async (req, res) => { if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' }); try { const result = await pool.query('SELECT * FROM together_groups WHERE id=$1', [req.params.groupId]); if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' }); res.json(result.rows[0]); } catch (e) { res.status(500).json({ error: 'Internal server error' }); } }); // GET /together/members/:groupId — メンバー一覧 r.get('/together/members/:groupId', async (req, res) => { try { const result = await pool.query( 'SELECT username, joined_at FROM together_members WHERE group_id=$1 ORDER BY joined_at', [req.params.groupId] ); res.json(result.rows); } catch (e) { res.status(500).json({ error: 'Internal server error' }); } }); // POST /together/share — 記事・テキストをシェア(即返却 + 非同期アーカイブ) r.post('/together/share', async (req, res) => { const { group_id, shared_by, url = null, title = null, message = '', tags = [] } = req.body || {}; if (!group_id || !shared_by) return res.status(400).json({ error: 'group_id と shared_by は必須です' }); if (url) { try { const p = new URL(url); if (!['http:', 'https:'].includes(p.protocol)) throw new Error(); } catch { return res.status(400).json({ error: 'url は http/https のみ有効です' }); } } try { const grpCheck = await pool.query('SELECT id FROM together_groups WHERE id=$1', [group_id]); if (grpCheck.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' }); const result = await pool.query( `INSERT INTO together_shares (group_id, shared_by, url, title, message, tags) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [group_id, shared_by, url, title, message, tags] ); const share = result.rows[0]; res.json({ ok: true, share }); // URL がある場合のみ非同期アーカイブ(ユーザーを待たせない) if (url) archiveShare(share.id, url); } catch (e) { console.error('[together/share]', e.message); res.status(500).json({ error: 'Internal server error' }); } }); // DELETE /together/share/:id — 自分の投稿を削除 r.delete('/together/share/:id', async (req, res) => { const { username } = req.body || {}; if (!username) return res.status(400).json({ error: 'username は必須です' }); try { const result = await pool.query( 'DELETE FROM together_shares WHERE id=$1 AND shared_by=$2 RETURNING id', [req.params.id, username] ); if (result.rows.length === 0) return res.status(403).json({ error: '削除できません' }); res.json({ ok: true }); } catch (e) { console.error('[together/share DELETE]', e.message); res.status(500).json({ error: 'Internal server error' }); } }); // GET /together/feed/:groupId — フィード(リアクション・コメント数付き) r.get('/together/feed/:groupId', async (req, res) => { try { const result = await pool.query(` SELECT s.*, COALESCE( json_agg(DISTINCT jsonb_build_object('username', r.username, 'type', r.type)) FILTER (WHERE r.username IS NOT NULL), '[]' ) AS reactions, COUNT(DISTINCT c.id)::int AS comment_count FROM together_shares s LEFT JOIN together_reactions r ON r.share_id = s.id LEFT JOIN together_comments c ON c.share_id = s.id WHERE s.group_id = $1 GROUP BY s.id ORDER BY s.shared_at DESC LIMIT 50 `, [req.params.groupId]); res.json(result.rows); } catch (e) { console.error('[together/feed]', e.message); res.status(500).json({ error: 'Internal server error' }); } }); // GET /together/article/:shareId — アーカイブ本文取得 r.get('/together/article/:shareId', async (req, res) => { try { const result = await pool.query( 'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1', [req.params.shareId] ); if (result.rows.length === 0) return res.status(404).json({ error: '見つかりません' }); res.json(result.rows[0]); } catch (e) { res.status(500).json({ error: 'Internal server error' }); } }); // POST /together/react — リアクション toggle(like / star / fire) r.post('/together/react', async (req, res) => { const { share_id, username, type = 'like' } = req.body || {}; if (!share_id || !username) return res.status(400).json({ error: 'share_id と username は必須です' }); if (!['like', 'star', 'fire'].includes(type)) return res.status(400).json({ error: 'type は like/star/fire のみ有効です' }); try { const existing = await pool.query( 'SELECT 1 FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3', [share_id, username, type] ); if (existing.rows.length > 0) { await pool.query( 'DELETE FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3', [share_id, username, type] ); res.json({ ok: true, action: 'removed' }); } else { await pool.query( 'INSERT INTO together_reactions (share_id, username, type) VALUES ($1, $2, $3)', [share_id, username, type] ); res.json({ ok: true, action: 'added' }); } } catch (e) { console.error('[together/react]', e.message); res.status(500).json({ error: 'Internal server error' }); } }); // GET /together/comments/:shareId — コメント一覧 r.get('/together/comments/:shareId', async (req, res) => { try { const result = await pool.query( 'SELECT * FROM together_comments WHERE share_id=$1 ORDER BY created_at', [req.params.shareId] ); res.json(result.rows); } catch (e) { res.status(500).json({ error: 'Internal server error' }); } }); // POST /together/comments — コメント投稿 r.post('/together/comments', async (req, res) => { const { share_id, username, body } = req.body || {}; if (!share_id || !username || !body?.trim()) { return res.status(400).json({ error: 'share_id, username, body は必須です' }); } try { const result = await pool.query( 'INSERT INTO together_comments (share_id, username, body) VALUES ($1, $2, $3) RETURNING *', [share_id, username, body.trim()] ); res.json(result.rows[0]); } catch (e) { console.error('[together/comments POST]', e.message); res.status(500).json({ error: 'Internal server error' }); } }); // GET /together/search/:groupId — キーワード / タグ検索 r.get('/together/search/:groupId', async (req, res) => { const { q = '', tag = '' } = req.query; if (!q && !tag) return res.status(400).json({ error: 'q または tag が必要です' }); try { const keyword = q ? `%${q}%` : ''; const result = await pool.query(` SELECT id, shared_by, url, title, message, tags, summary, archive_status, shared_at FROM together_shares WHERE group_id = $1 AND ( ($2 != '' AND (title ILIKE $2 OR message ILIKE $2 OR full_content ILIKE $2)) OR ($3 != '' AND $3 = ANY(tags)) ) ORDER BY shared_at DESC LIMIT 30 `, [req.params.groupId, keyword, tag]); res.json(result.rows); } catch (e) { console.error('[together/search]', e.message); res.status(500).json({ error: 'Internal server error' }); } }); // ── Atlas: GitHub scan proxy ─────────────────────────────────── r.get('/atlas/github-scan', (req, res) => { const token = req.query.token; const org = req.query.org || ''; if (!token) return res.status(400).json({ error: 'token required' }); const https = require('https'); function ghRequest(path, cb) { const options = { hostname: 'api.github.com', path, method: 'GET', family: 4, headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'Posimai-Atlas/1.0', 'X-GitHub-Api-Version': '2022-11-28', }, timeout: 12000, }; const r2 = https.request(options, (resp) => { let body = ''; resp.on('data', chunk => { body += chunk; }); resp.on('end', () => cb(null, resp.statusCode, body)); }); r2.on('timeout', () => { r2.destroy(); cb(new Error('Timeout')); }); r2.on('error', cb); r2.end(); } const orgPath = org ? `/orgs/${encodeURIComponent(org)}/repos?per_page=100&sort=updated` : null; const userPath = `/user/repos?per_page=100&sort=updated&affiliation=owner`; function handleResult(status, body) { if (status !== 200) return res.status(status).json({ error: body }); try { res.json(JSON.parse(body)); } catch (e) { res.status(500).json({ error: 'Invalid JSON' }); } } if (orgPath) { ghRequest(orgPath, (err, status, body) => { if (err) return res.status(500).json({ error: err.message }); // If org not accessible, fall back to user repos if (status === 404 || status === 403) { ghRequest(userPath, (err2, status2, body2) => { if (err2) return res.status(500).json({ error: err2.message }); // Signal to client that we fell back if (status2 === 200) { try { const data = JSON.parse(body2); return res.json({ repos: data, fallback: true }); } catch (e) { return res.status(500).json({ error: 'Invalid JSON' }); } } handleResult(status2, body2); }); } else { handleResult(status, body); } }); } else { ghRequest(userPath, (err, status, body) => { if (err) return res.status(500).json({ error: err.message }); handleResult(status, body); }); } }); // ── Atlas: Vercel scan proxy ─────────────────────────────────── r.get('/atlas/vercel-scan', (req, res) => { const token = req.query.token; if (!token) return res.status(400).json({ error: 'token required' }); const https = require('https'); const options = { hostname: 'api.vercel.com', path: '/v9/projects?limit=100', method: 'GET', family: 4, headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'Posimai-Atlas/1.0', }, timeout: 12000, }; const req2 = https.request(options, (r2) => { let body = ''; r2.on('data', chunk => { body += chunk; }); r2.on('end', () => { if (r2.statusCode !== 200) return res.status(r2.statusCode).json({ error: body }); try { res.json(JSON.parse(body)); } catch (e) { res.status(500).json({ error: 'Invalid JSON' }); } }); }); req2.on('timeout', () => { req2.destroy(); res.status(500).json({ error: 'Timeout' }); }); req2.on('error', (e) => { console.error('[proxy] error:', e.code, e.message); res.status(500).json({ error: 'Proxy error', code: e.code }); }); req2.end(); }); // ── Atlas: Tailscale scan proxy ──────────────────────────────── r.get('/atlas/tailscale-scan', (req, res) => { const token = req.query.token; if (!token) return res.status(400).json({ error: 'token required' }); const https = require('https'); const options = { hostname: 'api.tailscale.com', path: '/api/v2/tailnet/-/devices', method: 'GET', family: 4, // force IPv4; container IPv6 to tailscale times out headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'User-Agent': 'Posimai-Atlas/1.0', }, timeout: 12000, }; const req2 = https.request(options, (r2) => { let body = ''; r2.on('data', chunk => { body += chunk; }); r2.on('end', () => { if (r2.statusCode !== 200) { return res.status(r2.statusCode).json({ error: body }); } try { res.json(JSON.parse(body)); } catch (e) { res.status(500).json({ error: 'Invalid JSON from Tailscale' }); } }); }); req2.on('timeout', () => { req2.destroy(); res.status(500).json({ error: 'Request timed out' }); }); req2.on('error', (e) => { console.error('[atlas/tailscale-scan] error:', e.code, e.message); res.status(500).json({ error: 'Scan error', code: e.code }); }); req2.end(); }); return r; } // ─── Doorkeeper JSON API(認証不要・CORS 問題なし) ───────────────────────── async function fetchDoorkeeper() { const url = 'https://api.doorkeeper.jp/events?locale=ja&per_page=50&sort=starts_at'; const res = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'Posimai/1.0' }, signal: AbortSignal.timeout(8000), }); if (!res.ok) throw new Error(`Doorkeeper ${res.status}`); const data = await res.json(); return data.map((item) => { const ev = item.event; const start = new Date(ev.starts_at); const end = new Date(ev.ends_at || ev.starts_at); return { id: `doorkeeper-${ev.id}`, title: ev.title || '', url: ev.url || '', location: ev.venue_name || (ev.address ? ev.address.split(',')[0] : 'オンライン'), address: ev.address || '', startDate: evToDateStr(start), endDate: evToDateStr(end), startTime: evToTimeStr(start), endTime: evToTimeStr(end), category: 'IT イベント', description: evStripHtml(ev.description || '').slice(0, 300), source: ev.group || 'Doorkeeper', isFree: false, interestTags: evGuessInterestTags(ev.title + ' ' + (ev.description || '')), audienceTags: evGuessAudienceTags(ev.title + ' ' + (ev.description || '')), }; }); } // ─── connpass Atom RSS(XMLをregexでパース) ────────────────────────────────── async function fetchConnpassRss() { const url = 'https://connpass.com/explore/ja.atom'; const res = await fetch(url, { headers: { 'Accept': 'application/atom+xml', 'User-Agent': 'Posimai/1.0' }, signal: AbortSignal.timeout(8000), }); if (!res.ok) throw new Error(`connpass RSS ${res.status}`); const xml = await res.text(); const entries = [...xml.matchAll(/([\s\S]*?)<\/entry>/g)]; return entries.map((match, i) => { const c = match[1]; const title = evExtractXml(c, 'title'); const url = /]+href="([^"]+)"/.exec(c)?.[1] || ''; const updated = evExtractXml(c, 'updated'); const summary = evExtractXml(c, 'summary'); const author = evExtractXml(c, 'name'); const dt = updated ? new Date(updated) : new Date(); return { id: `connpass-${i}-${evToDateStr(dt)}`, title, url, location: 'connpass', address: '', startDate: evToDateStr(dt), endDate: evToDateStr(dt), startTime: evToTimeStr(dt), endTime: evToTimeStr(dt), category: 'IT イベント', description: summary.slice(0, 300), source: author || 'connpass', isFree: false, interestTags: evGuessInterestTags(title + ' ' + summary), audienceTags: evGuessAudienceTags(title + ' ' + summary), }; }); } function evExtractXml(xml, tag) { const m = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`).exec(xml); if (!m) return ''; return m[1].replace(//g, '') .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'").trim(); } function evStripHtml(html) { return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } function evToDateStr(dt) { return dt.toISOString().slice(0, 10); } function evToTimeStr(dt) { const jst = new Date(dt.getTime() + 9 * 3600 * 1000); return jst.toISOString().slice(11, 16); } function evGuessInterestTags(text) { const tags = []; if (/React|Vue|TypeScript|フロントエンド|Next\.js|Svelte/i.test(text)) tags.push('frontend'); if (/Go|Rust|Ruby|Python|PHP|バックエンド|API/i.test(text)) tags.push('backend'); if (/デザイン|UX|Figma|UI/i.test(text)) tags.push('design'); if (/AI|機械学習|LLM|GPT|Claude|Gemini/i.test(text)) tags.push('ai'); if (/インフラ|AWS|GCP|Azure|クラウド|Docker|Kubernetes/i.test(text)) tags.push('infra'); if (/iOS|Android|Flutter|React Native|モバイル/i.test(text)) tags.push('mobile'); if (/データ|分析|ML|データサイエンス/i.test(text)) tags.push('data'); if (/PM|プロダクト|プロダクトマネジメント/i.test(text)) tags.push('pm'); if (/初心者|入門|ビギナー/i.test(text)) tags.push('beginner'); return tags; } function evGuessAudienceTags(text) { const tags = []; if (/交流|ミートアップ|meetup/i.test(text)) tags.push('meetup'); if (/もくもく/i.test(text)) tags.push('mokumoku'); if (/セミナー|勉強会|study/i.test(text)) tags.push('seminar'); if (/ハンズオン|hands.?on/i.test(text)) tags.push('handson'); return tags; } // ── Uploads ─────────────────────────────── const UPLOADS_DIR = path.join(__dirname, 'uploads'); if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); app.use('/brain/api/uploads', express.static(UPLOADS_DIR)); app.use('/api/uploads', express.static(UPLOADS_DIR)); // ── Stripe Webhook ──────────────────────────────────────────────────── // rawBody が必要なため express.json() より前・router より前に配置 const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; async function handleStripeWebhook(req, res) { const sig = req.headers['stripe-signature']; if (!STRIPE_WEBHOOK_SECRET) { console.warn('[Stripe] STRIPE_WEBHOOK_SECRET not set — webhook disabled'); return res.status(503).json({ error: 'Webhook not configured' }); } if (!sig) return res.status(400).json({ error: 'Missing stripe-signature' }); // 署名検証(stripe ライブラリ不使用・軽量実装) let event; try { const crypto = require('crypto'); const rawBody = req.body; // Buffer const match = sig.match(/t=(\d+).*?,.*?v1=([a-fA-F0-9]+)/); if (!match || match.length < 3) throw new Error('Invalid signature format'); const [, tsStr, v1Sig] = match; const tolerance = 300; // 5分 if (Math.abs(Date.now() / 1000 - parseInt(tsStr)) > tolerance) { throw new Error('Timestamp too old'); } const payload = `${tsStr}.${rawBody.toString('utf8')}`; const expected = crypto.createHmac('sha256', STRIPE_WEBHOOK_SECRET).update(payload).digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1Sig))) { throw new Error('Signature mismatch'); } event = JSON.parse(rawBody.toString('utf8')); } catch (e) { console.error('[Stripe] Webhook verification failed:', e.message); return res.status(400).json({ error: 'Webhook verification failed' }); } // checkout.session.completed イベントのみ処理 if (event.type === 'checkout.session.completed') { const session = event.data.object; const email = session.customer_details?.email?.toLowerCase(); if (email) { try { // ユーザーが存在すれば purchased_at を記録、なければ作成してから記録 const existing = await pool.query( `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (existing.rows.length > 0) { userId = existing.rows[0].user_id; } else { const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); userId = `${baseId}_${Date.now().toString(36)}`; await pool.query( `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) ON CONFLICT (user_id) DO NOTHING`, [userId, baseId, email] ); } await pool.query( `UPDATE users SET purchased_at = NOW(), stripe_session_id = $1, plan = 'premium', stripe_customer_id = $2, stripe_subscription_id = $3 WHERE user_id = $4`, [session.id, session.customer, session.subscription, userId] ); console.log(`[Stripe] Plan upgraded to premium for ${email}`); } catch (e) { console.error('[Stripe] DB error recording purchase:', e.message); return res.status(500).json({ error: 'DB error' }); } } } // サブスクリプション解約 → plan を free に戻す if (event.type === 'customer.subscription.deleted') { const subscription = event.data.object; try { await pool.query( `UPDATE users SET plan = 'free', stripe_subscription_id = NULL WHERE stripe_customer_id = $1`, [subscription.customer] ); console.log(`[Stripe] Plan downgraded to free for customer: ${subscription.customer}`); } catch (e) { console.error('[Stripe] DB error downgrading plan:', e.message); } } res.json({ received: true }); } // rawBody を保持するため独自パーサーを使用 app.post('/brain/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => handleStripeWebhook(req, res) ); app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => handleStripeWebhook(req, res) ); // ── マウント(Tailscale経由と直接アクセスの両方対応) const router = buildRouter(); app.use('/brain/api', router); // Tailscale Funnel 経由 app.use('/api', router); // ローカル直接アクセス // ── 起動 ────────────────────────────────── const PORT = parseInt(process.env.PORT || '8090'); // ── Feed 背景取得ジョブ ──────────────────────────────────────────── // feed_media テーブルの全URLを15分ごとに取得し feed_articles へ upsert let feedFetchRunning = false; async function runFeedFetch() { if (!RssParser) return 0; if (feedFetchRunning) { console.log('[Feed] fetch already running, skip'); return 0; } feedFetchRunning = true; let totalNew = 0; try { const rssParser = new RssParser({ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Posimai/1.0)', 'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*' }, timeout: 10000 }); const mediasResult = await pool.query( `SELECT id, user_id, name, feed_url, category FROM feed_media WHERE is_active = true` ); const medias = mediasResult.rows; console.log(`[Feed] fetching ${medias.length} feeds...`); await Promise.allSettled(medias.map(async (media) => { try { const feed = await rssParser.parseURL(media.feed_url); const items = (feed.items || []).slice(0, 20); for (const item of items) { if (!item.link) continue; const publishedAt = item.pubDate || item.isoDate || null; try { const result = await pool.query( `INSERT INTO feed_articles (media_id, user_id, url, title, summary, author, published_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (user_id, url) DO NOTHING`, [ media.id, media.user_id, item.link, (item.title || '').slice(0, 500), (item.contentSnippet || item.content || item.description || '').slice(0, 1000), (item.creator || item.author || '').slice(0, 200), publishedAt ? new Date(publishedAt) : null ] ); totalNew += result.rowCount; } catch (_) { /* duplicate or DB error, skip */ } } // last_fetched_at 更新 await pool.query( `UPDATE feed_media SET last_fetched_at = NOW() WHERE id = $1`, [media.id] ); } catch (e) { console.warn(`[Feed] failed to fetch ${media.feed_url}: ${e.message}`); } })); // 古い記事を削除(30日以上前 + 既読) await pool.query( `DELETE FROM feed_articles WHERE published_at < NOW() - INTERVAL '30 days' AND is_read = true` ); console.log(`[Feed] fetch done. new articles: ${totalNew}`); } finally { feedFetchRunning = false; } return totalNew; } loadWebauthn() .then(() => initDB()) .then(() => { app.listen(PORT, '0.0.0.0', () => { console.log(`\nPosimai Brain API`); console.log(` Port: ${PORT}`); console.log(` Gemini: ${genAI ? 'enabled' : 'disabled (no key)'}`); console.log(` WebAuthn: rpID=${WEBAUTHN_RP_ID}`); console.log(` Users: ${Object.values(KEY_MAP).join(', ') || '(none - set API_KEYS)'}`); console.log(` Local: http://localhost:${PORT}/api/health`); console.log(` Public: https://api.soar-enrich.com/brain/api/health`); }); // 起動直後に1回取得し、以降15分ごとに繰り返す if (RssParser) { setTimeout(() => runFeedFetch().catch(e => console.error('[Feed] initial fetch error:', e.message)), 5000); setInterval(() => runFeedFetch().catch(e => console.error('[Feed] interval fetch error:', e.message)), 15 * 60 * 1000); console.log(' Feed: background fetch enabled (15min interval)'); } else { console.log(' Feed: background fetch disabled (rss-parser not installed)'); } }) .catch(err => { console.error('[FATAL] Startup failed:', err.message); process.exit(1); });