posimai-root/server.js

3052 lines
142 KiB
JavaScript
Raw Normal View History

// ============================================
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// ── Auth: JWT config ────────────────────────────────────────────────
if (!process.env.JWT_SECRET) {
console.error('[SECURITY] JWT_SECRET env var is not set. Refusing to start.');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
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: 15,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// プールレベルの接続エラーをキャッチ(未処理のままにしない)
pool.on('error', (err) => {
console.error('[DB] Unexpected pool error:', err.message);
});
// ── 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://store.posimai.soar-enrich.com/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';
}
// ── SSRF ガードfetchMeta / fetchFullTextViaJina 共用)──────────────
// RFC 1918 プライベート帯域・ループバック・クラウドメタデータ IP をブロック
const SSRF_BLOCKED = /^(127\.|localhost$|::1$|0\.0\.0\.0$|169\.254\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.100\.100\.100|metadata\.google\.internal)/i;
function isSsrfSafe(rawUrl) {
let parsed;
try { parsed = new URL(rawUrl); } catch { return false; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (SSRF_BLOCKED.test(parsed.hostname)) return false;
return true;
}
// ── OGP フェッチ ───────────────────────────
const FETCH_META_MAX_BYTES = 2 * 1024 * 1024; // 2 MB 上限
async function fetchMeta(url) {
if (!isSsrfSafe(url)) {
return { title: url.slice(0, 300), desc: '', ogImage: '', favicon: '' };
}
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}`);
// レスポンスサイズを 2MB に制限OGP取得にそれ以上は不要
const contentLength = parseInt(res.headers.get('content-length') || '0', 10);
if (contentLength > FETCH_META_MAX_BYTES) throw new Error('Response too large');
const rawBuffer = await res.arrayBuffer();
const buffer = rawBuffer.byteLength > FETCH_META_MAX_BYTES
? rawBuffer.slice(0, FETCH_META_MAX_BYTES)
: rawBuffer;
// 文字コード判定: 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) {
if (!isSsrfSafe(url)) return null;
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;
}
// レスポンスサイズを 1MB に制限AI 分析に必要な本文量の上限)
const jinaContentLength = parseInt(jinaResponse.headers.get('content-length') || '0', 10);
if (jinaContentLength > 1024 * 1024) return null;
let markdown = await jinaResponse.text();
if (markdown.length > 1024 * 1024) markdown = markdown.slice(0, 1024 * 1024);
// 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
)`,
`CREATE TABLE IF NOT EXISTS ponshu_licenses (
license_key TEXT PRIMARY KEY,
email TEXT NOT NULL,
plan VARCHAR(20) NOT NULL DEFAULT 'pro',
status VARCHAR(20) NOT NULL DEFAULT 'active',
device_id TEXT,
activated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
stripe_session_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
];
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(_) {}
let users = 0;
try {
const whoOut = execSync('who 2>/dev/null', { timeout: 1000 }).toString().trim();
users = whoOut ? whoOut.split('\n').filter(l => l.trim()).length : 0;
} 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,
users,
node_version: process.version,
});
});
// 認証テスト (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, redirect } = 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 redirectSuffix = redirect ? `&redirect=${encodeURIComponent(redirect)}` : '';
const magicLinkUrl = `${MAGIC_LINK_BASE_URL}/auth/verify?token=${token}${redirectSuffix}`;
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 <hello@soar-enrich.com>',
to: [normalizedEmail],
subject: 'Posimai ログインリンク',
html: `<p>以下のリンクをクリックして Posimai にログインしてください。</p>
<p><a href="${magicLinkUrl}" style="font-size:16px;font-weight:bold;">${magicLinkUrl}</a></p>
<p>このリンクは15分間有効です</p>
<p>このメールに心当たりがない場合は無視してください</p>`
})
});
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' });
}
});
// ========== 記事保存(即時保存 + バックグラウンドメタ取得)==========
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' });
const source = clientSource || extractSource(url);
const domain = parsedUrl.hostname;
try {
// 1. URLだけ即座にDBへ保存してフロントに返すメタ取得・AIはバックグラウンド
const 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 source=EXCLUDED.source, summary='⏳ 再分析中...'
RETURNING *
`, [req.userId, url, clientTitle || domain, content || null, '⏳ AI分析中...', ['その他'], source, 3,
`https://www.google.com/s2/favicons?domain=${domain}&sz=32`, '']);
const article = articleQuery.rows[0];
res.json({ ok: true, article, aiStatus: 'pending' });
// 2. バックグラウンドでメタ情報取得 → DB更新 → AI分析
const savedUserId = req.userId;
setImmediate(async () => {
try {
const meta = await fetchMeta(url);
let fullText = content || null;
if (!fullText || fullText.trim().length === 0) {
const jinaText = await fetchFullTextViaJina(url);
fullText = jinaText || meta.desc || '';
}
const finalTitle = clientTitle || meta.title;
await pool.query(`
UPDATE articles SET title=$1, full_text=$2, favicon=$3, og_image=$4
WHERE user_id=$5 AND url=$6
`, [finalTitle, fullText, meta.favicon, meta.ogImage, savedUserId, url]);
if (checkRateLimit('gemini_analyze', savedUserId, 50, 60 * 60 * 1000)) {
analyzeWithGemini(finalTitle, 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, savedUserId, url]);
console.log(`[Brain API] ✓ AI analysis completed for ${url}`);
}).catch(e => console.error('[Background AI Error]:', e));
}
} catch (e) {
console.error('[Background Meta Error]:', e.message);
}
});
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' });
console.error(e);
if (!res.headersSent) 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) — 即時保存 + バックグラウンドメタ取得
r.get('/quick-save', authMiddleware, async (req, res) => {
const url = req.query.url;
if (!url) return res.status(400).send('<h1>URL not provided</h1>');
let parsedUrl;
try { parsedUrl = new URL(url); } catch { return res.status(400).send('<h1>Invalid URL</h1>'); }
const domain = parsedUrl.hostname;
const source = extractSource(url);
try {
// 1. URLだけ即座に保存
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 source=EXCLUDED.source, summary='⏳ 再分析中...'
`, [req.userId, url, domain, null, '⏳ AI分析中...', ['その他'], source, 3,
`https://www.google.com/s2/favicons?domain=${domain}&sz=32`, '']);
// 2. HTMLレスポンスを即座に返す
res.send(`
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>保存完了</title></head>
<body style="font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2">
<h1 style="color:#6EE7B7"> 保存しました</h1>
<p style="color:#888">${escapeHtml(domain)}</p>
<p style="color:#888">タイトルAI分析をバックグラウンドで取得中...</p>
<script>setTimeout(() => window.close(), 1200)</script>
</body></html>
`);
// 3. バックグラウンドでメタ情報取得 → DB更新 → AI分析
const savedUserId = req.userId;
setImmediate(async () => {
try {
const meta = await fetchMeta(url);
const jinaText = await fetchFullTextViaJina(url);
const fullText = jinaText || meta.desc || '';
await pool.query(`
UPDATE articles SET title=$1, full_text=$2, favicon=$3, og_image=$4
WHERE user_id=$5 AND url=$6
`, [meta.title, fullText, meta.favicon, meta.ogImage, savedUserId, url]);
if (checkRateLimit('gemini_analyze', savedUserId, 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, savedUserId, url]);
}).catch(e => console.error('[Background AI Error]:', e));
}
} catch (e) {
console.error('[Background Meta Error]:', e.message);
}
});
} catch (e) {
if (!res.headersSent) res.status(500).send(`<h1>保存失敗: ${escapeHtml(e.message)}</h1>`);
}
});
// ========== 履歴機能 ==========
// 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 ArticlesDBキャッシュから高速配信──────────────────────
// 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, purchaseMiddleware, 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, purchaseMiddleware, (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, purchaseMiddleware, 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: 'イベント取得に失敗しました' });
}
});
// ── 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 || !isSsrfSafe(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}`);
let fullContent = await jinaRes.text();
// レスポンスサイズを 1MB に制限DB の full_content カラムおよびGemini入力量の上限
if (fullContent.length > 1024 * 1024) fullContent = fullContent.slice(0, 1024 * 1024);
// 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 id, name, created_at 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) {
if (!isSsrfSafe(url)) 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 memberCheck = await pool.query(
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
[group_id, shared_by]
);
if (memberCheck.rows.length === 0) return res.status(403).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 {
// shared_by が一致する行のみ削除(なければ 403
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 — リアクション togglelike / 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 {
// share の group に対してメンバーであることを確認
const memberCheck = await pool.query(
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2',
[share_id, username]
);
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
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 {
// share の group に対してメンバーであることを確認
const memberCheck = await pool.query(
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2',
[share_id, username]
);
if (memberCheck.rows.length === 0) return res.status(403).json({ error: 'グループのメンバーではありません' });
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.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
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.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
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.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
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 RSSXMLを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(/<entry>([\s\S]*?)<\/entry>/g)];
return entries.map((match, i) => {
const c = match[1];
const title = evExtractXml(c, 'title');
const url = /<link[^>]+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(/<!\[CDATA\[|\]\]>/g, '')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/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 (routes/stripe.js) ────────────────────────────────
// rawBody が必要なため express.json() より前・router より前に配置
const { handleWebhook: handleStripeWebhook } = require('./routes/stripe')(pool);
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)
);
// ── Ponshu Room ライセンス (routes/ponshu.js) ─────────────────────────
const ponshuRouter = require('./routes/ponshu')(pool, authMiddleware);
app.use('/brain/api', ponshuRouter);
app.use('/api', ponshuRouter);
// ── マウント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);
});