posimai-root/server.js

3052 lines
142 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ============================================
// 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);
});