posimai-root/server.js

2435 lines
111 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');
// ── 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);
// ── Auth: JWT config ────────────────────────────────────────────────
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-CHANGE-IN-PRODUCTION';
const JWT_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
// WebAuthn relying party config (from env)
const WEBAUTHN_RP_NAME = process.env.WEBAUTHN_RP_NAME || 'Posimai';
const WEBAUTHN_RP_ID = process.env.WEBAUTHN_RP_ID || 'localhost';
const WEBAUTHN_ORIGINS = (process.env.WEBAUTHN_ORIGINS || 'http://localhost:3000')
.split(',').map(o => o.trim()).filter(Boolean);
const MAGIC_LINK_BASE_URL = process.env.MAGIC_LINK_BASE_URL || 'http://localhost:3000';
// ── Auth: session helpers ────────────────────────────────────────────
async function createSessionJWT(userId) {
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + JWT_TTL_SECONDS * 1000);
const tokenHash = crypto.createHash('sha256').update(sessionId).digest('hex');
await pool.query(
`INSERT INTO auth_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)`,
[sessionId, userId, tokenHash, expiresAt]
);
return jwt.sign({ userId, sid: sessionId }, JWT_SECRET, { expiresIn: JWT_TTL_SECONDS });
}
const app = express();
app.use(express.json({ limit: '10mb' }));
// ── 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 true; // 同一オリジン
if (process.env.NODE_ENV !== 'production' && /^http:\/\/localhost(:\d+)?$/.test(origin)) return true; // localhost 開発のみ
if (/^https:\/\/posimai-[^.]+\.vercel\.app$/.test(origin)) return true; // 全 Posimai アプリ
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();
});
app.use(cors({
origin: (origin, cb) => {
if (isAllowedOrigin(origin)) cb(null, true);
else cb(new Error('CORS not allowed'));
},
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// ── PostgreSQL ────────────────────────────
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'gitea',
password: process.env.DB_PASSWORD || '',
database: 'posimai_brain',
max: 5
});
// ── Gemini ────────────────────────────────
const genAI = process.env.GEMINI_API_KEY
? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null;
// Together 専用インスタンス(メインキーを共用)
const genAITogether = genAI;
// ── API Key 認証 ──────────────────────────
// API_KEYS="pk_maita_abc:maita,pk_partner_def:partner"
const KEY_MAP = {};
(process.env.API_KEYS || '').split(',').forEach(pair => {
const [key, userId] = pair.trim().split(':');
if (key && userId) KEY_MAP[key.trim()] = userId.trim();
});
function authMiddleware(req, res, next) {
let token = '';
// 1. ヘッダーからの取得
const auth = req.headers.authorization || '';
if (auth.toLowerCase().startsWith('bearer ')) {
token = auth.substring(7).trim();
}
// 2. クエリパラメータからの取得 (Bookmarklet等)
else if (req.query.key) {
token = req.query.key.trim();
}
if (!token) return res.status(401).json({ error: '認証エラー: トークンがありません' });
// JWT session token (3-part dot format, not pk_ prefix)
if (!token.startsWith('pk_') && token.includes('.')) {
try {
const payload = jwt.verify(token, JWT_SECRET);
req.userId = payload.userId;
req.authType = 'session';
return next();
} catch (e) {
return res.status(401).json({ error: '認証エラー: セッションが無効または期限切れです' });
}
}
// API key (legacy)
const userId = KEY_MAP[token];
if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' });
req.userId = userId;
req.authType = 'apikey';
next();
}
// ── ソース抽出 ────────────────────────────
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'; }
}
// ── OGP フェッチ ───────────────────────────
async function fetchMeta(url) {
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PosimaiBot/1.0)' },
signal: AbortSignal.timeout(6000)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
// 文字コード判定先頭2000バイトからcharsetを探す
const headSnippet = new TextDecoder('utf-8', { fatal: false }).decode(buffer.slice(0, 2000));
let encoding = 'utf-8';
const charsetMatch = headSnippet.match(/charset=["']?(shift_jis|euc-jp|utf-8)["']?/i);
if (charsetMatch && charsetMatch[1]) {
encoding = charsetMatch[1].toLowerCase();
}
const html = new TextDecoder(encoding).decode(buffer);
const doc = parse(html);
const og = (p) => doc.querySelector(`meta[property="${p}"]`)?.getAttribute('content') || '';
const meta = (n) => doc.querySelector(`meta[name="${n}"]`)?.getAttribute('content') || '';
const title = og('og:title') || doc.querySelector('title')?.text || url;
const desc = og('og:description') || meta('description') || '';
const img = og('og:image') || '';
// Google Favicon API優先→ favicon.icoフォールバック
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`;
return {
title: title.trim().slice(0, 300), desc: desc.trim().slice(0, 500),
ogImage: img, favicon: faviconUrl
};
} catch {
let host = '';
try {
try { host = new URL(url).hostname; } catch { }
return {
title: url.slice(0, 300), desc: '', ogImage: '',
favicon: host ? `https://www.google.com/s2/favicons?domain=${host}&sz=32` : ''
};
} catch { return { title: url.slice(0, 300), desc: '', ogImage: '', favicon: '' }; }
}
}
// ── Jina Reader API フェッチ(新規追加)───
async function fetchFullTextViaJina(url) {
try {
console.log(`[Brain API] Fetching full text via Jina Reader for: ${url}`);
const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
headers: {
'User-Agent': 'Mozilla/5.0 Posimai Brain Bot'
},
signal: AbortSignal.timeout(15000)
});
if (!jinaResponse.ok) {
console.warn(`[Brain API] Jina Reader returned status ${jinaResponse.status}`);
return null;
}
let markdown = await jinaResponse.text();
// Markdown Content: マーカーの後ろを抽出
const contentMarker = 'Markdown Content:';
const contentIndex = markdown.indexOf(contentMarker);
if (contentIndex !== -1) {
markdown = markdown.substring(contentIndex + contentMarker.length).trim();
}
// 画像参照を除去Readerと同じロジック
markdown = markdown.replace(/!\[Image\s+\d+[^\]]*\]\([^)]*\)/gmi, '');
markdown = markdown.replace(/!\[Image\s+\d+[^\]]*\]/gmi, '');
markdown = markdown.replace(/^\s*\*?\s*!\[?Image\s+\d+[^\n]*/gmi, '');
markdown = markdown.replace(/\[\]\([^)]*\)/gm, '');
console.log(`[Brain API] ✓ Fetched ${markdown.length} chars via Jina Reader`);
return markdown;
} catch (error) {
console.error('[Brain API] Jina Reader fetch failed:', error.message);
return null;
}
}
// ── Gemini 分析 ───────────────────────────
const TOPIC_CANDIDATES = [
'AI', 'React', 'Next.js', 'TypeScript', 'Node.js',
'Flutter', 'Docker', 'PostgreSQL',
'日本酒', 'ガジェット', 'キャリア', 'その他'
];
function smartExtract(text, maxLen) {
if (!text || text.length <= maxLen) return text || '';
const halfLen = Math.floor(maxLen / 2);
const front = text.substring(0, halfLen);
const back = text.substring(text.length - halfLen);
return front + '\n\n[...中略...]\n\n' + back;
}
async function analyzeWithGemini(title, fullText, url) {
if (!genAI) return { summary: (fullText || '').slice(0, 120) || '(要約なし)', topics: ['その他'], readingTime: 3 };
try {
const model = genAI.getGenerativeModel({
model: 'gemini-2.0-flash-lite',
generationConfig: { responseMimeType: 'application/json' }
});
const prompt = `記事分析してJSONで返答:
タイトル: ${title.slice(0, 100)}
本文:
${smartExtract(fullText || '', 5000)}
{"summary":"3文要約","topics":["最大2個"],"readingTime":3}
候補: ${TOPIC_CANDIDATES.slice(0, 15).join(',')}`;
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Gemini timeout')), 15000)
);
let result;
result = await Promise.race([
model.generateContent(prompt),
timeoutPromise
]);
let raw = result.response.text().trim();
// ```json や ``` で囲まれている場合の除去を堅牢化
raw = raw.replace(/^```(json)?/i, '').replace(/```$/i, '').trim();
const parsed = JSON.parse(raw);
const validTopics = (parsed.topics || [])
.filter(t => TOPIC_CANDIDATES.includes(t)).slice(0, 2);
if (validTopics.length === 0) validTopics.push('その他');
return {
summary: String(parsed.summary || '').slice(0, 300),
topics: validTopics,
readingTime: Math.max(1, parseInt(parsed.readingTime) || 3)
};
} catch (e) {
console.error('[Gemini] FULL ERROR:', e);
if (typeof result !== 'undefined' && result?.response) {
console.error('[Gemini] Raw response:', result.response.text());
}
return {
summary: 'AI分析に失敗しました。しばらく後にお試しください。',
topics: ['その他'],
readingTime: 3
};
}
}
// ── DB 初期化 (起動時に自動実行) ─────────
async function initDB() {
// 1. posimai_brain DB を作成(なければ)
const admin = new Pool({
host: process.env.DB_HOST || 'db', port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'gitea', password: process.env.DB_PASSWORD || '',
database: 'gitea'
});
try {
await admin.query('CREATE DATABASE posimai_brain');
console.log('[DB] Created database posimai_brain');
} catch (e) {
if (e.code !== '42P04') throw e;
console.log('[DB] Database already exists, skipping');
} finally { await admin.end(); }
// 2. テーブル定義 — 1文ずつ実行マルチステートメントのエラーを防止
const schema = [
`CREATE TABLE IF NOT EXISTS users (
user_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
summary TEXT,
topics TEXT[] DEFAULT '{}',
source VARCHAR(100),
status VARCHAR(20) DEFAULT 'inbox',
previous_status VARCHAR(20) DEFAULT 'inbox',
reading_time INT DEFAULT 3,
favicon TEXT,
og_image TEXT,
saved_at TIMESTAMPTZ DEFAULT NOW(),
read_at TIMESTAMPTZ,
full_text TEXT,
UNIQUE(user_id, url)
)`,
`CREATE INDEX IF NOT EXISTS idx_articles_user_id ON articles(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_articles_saved_at ON articles(saved_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(user_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_articles_topics ON articles USING GIN(topics)`,
`CREATE TABLE IF NOT EXISTS reading_history (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
url TEXT NOT NULL,
title TEXT,
domain VARCHAR(200),
read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, url)
)`,
`CREATE TABLE IF NOT EXISTS journal_posts (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_journal_user_id ON journal_posts(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_journal_updated ON journal_posts(updated_at DESC)`,
// site_config: CMS サイト設定テーブル
`CREATE TABLE IF NOT EXISTS site_config (
key VARCHAR(50) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// ── Habit ──────────────────────────────
`CREATE TABLE IF NOT EXISTS habit_habits (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
icon VARCHAR(50) NOT NULL DEFAULT 'check',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS habit_log (
user_id VARCHAR(50) NOT NULL,
habit_id INTEGER NOT NULL REFERENCES habit_habits(id) ON DELETE CASCADE,
log_date DATE NOT NULL,
PRIMARY KEY (user_id, habit_id, log_date)
)`,
// ── Pulse ──────────────────────────────
`CREATE TABLE IF NOT EXISTS pulse_log (
user_id VARCHAR(50) NOT NULL,
log_date DATE NOT NULL,
mood SMALLINT CHECK (mood BETWEEN 1 AND 5),
energy SMALLINT CHECK (energy BETWEEN 1 AND 5),
focus SMALLINT CHECK (focus BETWEEN 1 AND 5),
note TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, log_date)
)`,
// ── Lens ───────────────────────────────
`CREATE TABLE IF NOT EXISTS lens_history (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
filename VARCHAR(255),
exif_data JSONB NOT NULL DEFAULT '{}',
thumbnail TEXT,
scanned_at TIMESTAMPTZ DEFAULT NOW()
)`,
// ── Together ────────────────────────────
`CREATE TABLE IF NOT EXISTS together_groups (
id SERIAL PRIMARY KEY,
invite_code VARCHAR(8) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS together_members (
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (group_id, username)
)`,
`CREATE TABLE IF NOT EXISTS together_shares (
id SERIAL PRIMARY KEY,
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
shared_by VARCHAR(50) NOT NULL,
url TEXT,
title TEXT,
message TEXT NOT NULL DEFAULT '',
og_image TEXT,
tags TEXT[] DEFAULT '{}',
full_content TEXT,
summary TEXT,
archive_status VARCHAR(10) NOT NULL DEFAULT 'pending',
shared_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_shares_group ON together_shares(group_id, shared_at DESC)`,
`CREATE TABLE IF NOT EXISTS together_reactions (
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'like',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (share_id, username, type)
)`,
`CREATE TABLE IF NOT EXISTS together_comments (
id SERIAL PRIMARY KEY,
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`,
// ── Auth ───────────────────────────────────
`CREATE TABLE IF NOT EXISTS magic_link_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
token VARCHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_mlt_token ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS idx_mlt_email ON magic_link_tokens(email, expires_at)`,
`CREATE TABLE IF NOT EXISTS passkey_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL,
public_key BYTEA NOT NULL,
counter BIGINT DEFAULT 0,
device_type VARCHAR(32),
transports TEXT[],
display_name VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ
)`,
`CREATE INDEX IF NOT EXISTS idx_pk_user ON passkey_credentials(user_id)`,
`CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
token_hash VARCHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ DEFAULT NOW(),
user_agent TEXT,
ip_address INET
)`,
`CREATE INDEX IF NOT EXISTS idx_as_token_hash ON auth_sessions(token_hash)`,
`CREATE INDEX IF NOT EXISTS idx_as_user_id ON auth_sessions(user_id)`,
`CREATE TABLE IF NOT EXISTS magic_link_rate_limit (
email VARCHAR(255) PRIMARY KEY,
attempt_count INT DEFAULT 1,
window_start TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS webauthn_user_handles (
user_id VARCHAR(50) PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
user_handle TEXT UNIQUE NOT NULL
)`,
];
for (const sql of schema) {
await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message));
}
// 3. マイグレーション — 既存テーブルへのカラム追加(全て安全に実行)
const migrations = [
`ALTER TABLE articles ADD COLUMN IF NOT EXISTS previous_status VARCHAR(20) DEFAULT 'inbox'`,
`ALTER TABLE articles ADD COLUMN IF NOT EXISTS full_text TEXT`,
`ALTER TABLE journal_posts ADD COLUMN IF NOT EXISTS published BOOLEAN NOT NULL DEFAULT FALSE`,
`CREATE INDEX IF NOT EXISTS idx_journal_published ON journal_posts(published)`,
// site_config をユーザー別に分離(既存データは maita に帰属)
`ALTER TABLE site_config ADD COLUMN IF NOT EXISTS user_id VARCHAR(50) NOT NULL DEFAULT 'maita'`,
`ALTER TABLE site_config DROP CONSTRAINT IF EXISTS site_config_pkey`,
`ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`,
// together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理
// ── Auth migrations ───────────────────────
`ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false`,
];
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();
// ヘルスチェック
r.get('/health', (req, res) => {
res.json({ status: 'ok', gemini: !!genAI, users: Object.values(KEY_MAP).length });
});
// 認証テスト (UI用)
r.get('/auth-test', authMiddleware, (req, res) => {
res.json({ ok: true, userId: req.userId });
});
// ── Auth: Magic Link ─────────────────────────────────────────────
// POST /api/auth/magic-link/send
r.post('/auth/magic-link/send', async (req, res) => {
const { email } = req.body;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: 'メールアドレスが無効です' });
}
const normalizedEmail = email.toLowerCase().trim();
try {
// Rate limit: 3 requests per 10 min per email
const rl = await pool.query(
`SELECT attempt_count, window_start FROM magic_link_rate_limit WHERE email = $1`,
[normalizedEmail]
);
if (rl.rows.length > 0) {
const row = rl.rows[0];
const windowAgeMinutes = (Date.now() - new Date(row.window_start).getTime()) / 60000;
if (windowAgeMinutes < 10 && row.attempt_count >= 3) {
return res.status(429).json({ error: '送信制限: 10分後に再試行してください' });
}
if (windowAgeMinutes >= 10) {
await pool.query(
`UPDATE magic_link_rate_limit SET attempt_count = 1, window_start = NOW() WHERE email = $1`,
[normalizedEmail]
);
} else {
await pool.query(
`UPDATE magic_link_rate_limit SET attempt_count = attempt_count + 1 WHERE email = $1`,
[normalizedEmail]
);
}
} else {
await pool.query(
`INSERT INTO magic_link_rate_limit (email) VALUES ($1)`,
[normalizedEmail]
);
}
// Generate token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
await pool.query(
`INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ($1, $2, $3)`,
[normalizedEmail, token, expiresAt]
);
// Send email via Resend (if API key is set)
if (process.env.RESEND_API_KEY) {
const magicLinkUrl = `${MAGIC_LINK_BASE_URL}/auth/verify?token=${token}`;
try {
const emailRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.RESEND_API_KEY}`
},
body: JSON.stringify({
from: 'Posimai <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
r.get('/auth/session/verify', authMiddleware, (req, res) => {
res.json({ ok: true, userId: req.userId, authType: req.authType });
});
// 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;
const challengeKey = email ? `login:${email.toLowerCase().trim()}` : `login:anon:${crypto.randomBytes(8).toString('hex')}`;
try {
let allowCredentials = [];
if (email) {
const normalizedEmail = email.toLowerCase().trim();
const userResult = await pool.query(
`SELECT u.user_id FROM users u WHERE u.email = $1`,
[normalizedEmail]
);
if (userResult.rows.length > 0) {
const userId = userResult.rows[0].user_id;
const creds = await pool.query(
`SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $1`,
[userId]
);
allowCredentials = creds.rows.map(row => ({
id: row.credential_id,
transports: row.transports || []
}));
}
}
const options = await webauthn.generateAuthenticationOptions({
rpID: WEBAUTHN_RP_ID,
allowCredentials,
userVerification: 'preferred'
});
webauthnChallenges.set(challengeKey, {
challenge: options.challenge,
email: email ? email.toLowerCase().trim() : null,
expiresAt: Date.now() + 5 * 60 * 1000
});
res.json({ ...options, _challengeKey: challengeKey });
} catch (e) {
console.error('[Auth] Passkey login begin error:', e);
res.status(500).json({ error: 'パスキーログインの開始に失敗しました' });
}
});
// POST /api/auth/passkey/login/finish
r.post('/auth/passkey/login/finish', async (req, res) => {
if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' });
const { _challengeKey, ...assertionResponse } = req.body;
if (!_challengeKey) return res.status(400).json({ error: 'challengeKey が必要です' });
const challengeEntry = webauthnChallenges.get(_challengeKey);
if (!challengeEntry || challengeEntry.expiresAt < Date.now()) {
return res.status(400).json({ error: 'ログインセッションが期限切れです。最初からやり直してください' });
}
try {
// Find credential in DB
const credResult = await pool.query(
`SELECT c.*, c.user_id FROM passkey_credentials c WHERE c.credential_id = $1`,
[assertionResponse.id]
);
if (credResult.rows.length === 0) {
return res.status(401).json({ error: 'パスキーが見つかりません' });
}
const cred = credResult.rows[0];
const verification = await webauthn.verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge: challengeEntry.challenge,
expectedOrigin: WEBAUTHN_ORIGINS,
expectedRPID: WEBAUTHN_RP_ID,
credential: {
id: cred.credential_id,
publicKey: new Uint8Array(cred.public_key),
counter: Number(cred.counter),
transports: cred.transports || []
}
});
if (!verification.verified) {
return res.status(401).json({ error: 'パスキーの検証に失敗しました' });
}
webauthnChallenges.delete(_challengeKey);
// Update counter and last_used_at
await pool.query(
`UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE credential_id = $2`,
[verification.authenticationInfo.newCounter, cred.credential_id]
);
const sessionToken = await createSessionJWT(cred.user_id);
res.json({ ok: true, token: sessionToken, userId: cred.user_id });
} catch (e) {
console.error('[Auth] Passkey login finish error:', e);
res.status(500).json({ error: 'パスキーログインに失敗しました' });
}
});
// GET /api/auth/passkeys — list user's registered passkeys
r.get('/auth/passkeys', authMiddleware, async (req, res) => {
try {
const result = await pool.query(
`SELECT id, credential_id, device_type, transports, display_name, created_at, last_used_at
FROM passkey_credentials WHERE user_id = $1 ORDER BY created_at DESC`,
[req.userId]
);
res.json({ passkeys: result.rows });
} catch (e) {
console.error('[Auth] List passkeys error:', e);
res.status(500).json({ error: 'サーバーエラーが発生しました' });
}
});
// DELETE /api/auth/passkeys/:id — remove a passkey
r.delete('/auth/passkeys/:id', authMiddleware, async (req, res) => {
try {
const result = await pool.query(
`DELETE FROM passkey_credentials WHERE id = $1 AND user_id = $2 RETURNING id`,
[req.params.id, req.userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'パスキーが見つかりません' });
}
res.json({ ok: true });
} catch (e) {
console.error('[Auth] Delete passkey error:', e);
res.status(500).json({ error: 'サーバーエラーが発生しました' });
}
});
// 記事一覧取得
r.get('/articles', authMiddleware, async (req, res) => {
const { status, topic, source, q } = req.query;
let sql = `SELECT id, url, title, summary, topics, source, status, previous_status, reading_time, favicon, saved_at, (full_text IS NOT NULL) AS has_full_text FROM articles WHERE user_id = $1`;
const params = [req.userId];
let i = 2;
if (status) { sql += ` AND status = $${i++}`; params.push(status); }
if (topic) { sql += ` AND $${i++} = ANY(topics)`; params.push(topic); }
if (source) { sql += ` AND source ILIKE $${i++}`; params.push(source); }
if (q) {
sql += ` AND (title ILIKE $${i} OR summary ILIKE $${i})`;
params.push(`%${q}%`); i++;
}
sql += ' ORDER BY saved_at DESC LIMIT 300';
try {
const { rows } = await pool.query(sql, params);
// カウント計算
const counts = {
all: rows.length,
unread: rows.filter(a => a.status === 'inbox').length,
favorite: rows.filter(a => a.status === 'favorite').length,
shared: rows.filter(a => a.status === 'shared').length
};
res.json({ articles: rows, counts });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// 記事詳細取得(full_text を含む完全版)
r.get('/articles/:id', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM articles WHERE id=$1 AND user_id=$2',
[req.params.id, req.userId]
);
if (rows.length === 0) return res.status(404).json({ error: 'Article not found' });
return res.json({ article: rows[0] });
} catch (e) {
console.error('[Brain API] GET /articles/:id failed:', e.stack || e);
return res.status(500).json({ error: 'DB error' });
}
});
// ========== 記事保存Jina Reader自動取得対応==========
r.post('/save', authMiddleware, async (req, res) => {
const { url, title: clientTitle, content, source: clientSource } = req.body || {};
if (!url) return res.status(400).json({ error: 'url is required' });
let parsedUrl;
try { parsedUrl = new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
if (!['http:', 'https:'].includes(parsedUrl.protocol))
return res.status(400).json({ error: 'Only http/https' });
try {
const meta = await fetchMeta(url);
let fullText = content || null;
const source = clientSource || extractSource(url);
// 重要: contentが空の場合、Jina Reader APIで本文を自動取得
if (!fullText || fullText.trim().length === 0) {
console.log(`[Brain API] No content provided for ${url}, attempting Jina Reader fetch...`);
const jinaText = await fetchFullTextViaJina(url);
if (jinaText && jinaText.length > 0) {
fullText = jinaText;
console.log(`[Brain API] ✓ Using Jina Reader full text (${fullText.length} chars)`);
} else {
// Jina Reader失敗時はOGP descriptionをフォールバック
console.log(`[Brain API] ⚠ Jina Reader failed, falling back to OGP description`);
fullText = meta.desc || '';
}
} else {
console.log(`[Brain API] Using provided content (${fullText.length} chars)`);
}
// 即座に保存してフロントに返すAIはバックグラウンド
let articleQuery = await pool.query(`
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, url) DO UPDATE
SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...'
RETURNING *
`, [req.userId, url, clientTitle || meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
let article = articleQuery.rows[0];
res.json({ ok: true, article, aiStatus: 'pending' });
// バックグラウンドでAI処理
analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => {
await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
console.log(`[Brain API] ✓ AI analysis completed for ${url}`);
}).catch(e => console.error('[Background AI Error]:', e));
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' });
console.error(e); res.status(500).json({ error: 'DB error' });
}
});
// ステータス更新
r.patch('/articles/:id/status', authMiddleware, async (req, res) => {
const { status } = req.body || {};
const valid = ['inbox', 'favorite', 'shared'];
if (!valid.includes(status)) return res.status(400).json({ error: 'Invalid status' });
try {
const readAt = status === 'shared' || status === 'reading' ? 'NOW()' : 'NULL';
if (status === 'favorite') {
await pool.query(
`UPDATE articles SET previous_status=status, status=$1, read_at=${readAt === 'NULL' ? 'NULL' : 'NOW()'}
WHERE id=$2 AND user_id=$3`,
[status, req.params.id, req.userId]
);
} else {
await pool.query(
`UPDATE articles SET status=$1, read_at=${readAt === 'NULL' ? 'NULL' : 'NOW()'}
WHERE id=$2 AND user_id=$3`,
[status, req.params.id, req.userId]
);
}
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: 'DB error' }); }
});
// 削除
r.delete('/articles/:id', authMiddleware, async (req, res) => {
try {
await pool.query('DELETE FROM articles WHERE id=$1 AND user_id=$2', [req.params.id, req.userId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: 'DB error' }); }
});
// クイック保存 (Bookmarklet等からのGET) — Jina Reader対応
r.get('/quick-save', authMiddleware, async (req, res) => {
const url = req.query.url;
if (!url) return res.status(400).send('<h1>URL not provided</h1>');
try {
const meta = await fetchMeta(url);
const source = extractSource(url);
// Jina Readerで本文取得を試みる
let fullText = await fetchFullTextViaJina(url);
if (!fullText || fullText.length === 0) {
fullText = meta.desc || '';
}
await pool.query(`
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, url) DO UPDATE
SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...'
`, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
// バックグラウンドAI
analyzeWithGemini(meta.title, fullText, url).then(async (ai) => {
await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
}).catch(e => console.error('[Background AI Error]:', e));
// HTMLレスポンス自動で閉じる
res.send(`
<!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:#818CF8">✓ 保存しました</h1>
<p>${meta.title}</p>
<p style="color:#888">AI分析をバックグラウンドで開始しました</p>
<script>setTimeout(() => window.close(), 1500)</script>
</body></html>
`);
} catch (e) {
res.status(500).send(`<h1>保存失敗: ${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' });
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 {
const result = await pool.query(
'SELECT id, name, feed_url, site_url, category, is_active, created_at FROM feed_media WHERE user_id=$1 ORDER BY 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 = '', category = 'tech', is_active = true } = req.body || {};
if (!name || !feed_url) return res.status(400).json({ error: 'name and feed_url required' });
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) { 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' }); }
});
// ── TTS (VOICEVOX) ─────────────────────────────────────────────
// VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021
// 未設定時は localhost フォールバックNAS上で同じコンテナホストの場合
const VOICEVOX_URL = process.env.VOICEVOX_URL || 'http://voicevox:50021';
const ttsCache = new Map(); // key: "speaker:text" → Buffer
const TTS_CACHE_MAX = 60; // 約60文を最大キャッシュメモリ節約
let ttsBusy = false; // VOICEVOX は同時1リクエストのみ対応排他ロック
let preWarmBusy = false; // プリウォームが合成中(ユーザーリクエストを優先するために分離)
let userWaiting = false; // ユーザーリクエスト待機中 → プリウォームをスキップ
// 合成ヘルパー(/tts と /tts/warmup から共用)
async function ttsSynthesize(text, speaker) {
const queryRes = await fetch(
`${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
{ method: 'POST' }
);
if (!queryRes.ok) throw new Error(`VOICEVOX audio_query failed: ${queryRes.status}`);
const query = await queryRes.json();
query.speedScale = 1.08;
query.intonationScale = 1.15;
query.prePhonemeLength = 0.05;
query.postPhonemeLength = 0.1;
const synthRes = await fetch(`${VOICEVOX_URL}/synthesis?speaker=${speaker}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query)
});
if (!synthRes.ok) throw new Error(`VOICEVOX synthesis failed: ${synthRes.status}`);
return Buffer.from(await synthRes.arrayBuffer());
}
// POST /tts — テキストを音声WAVに変換して返す
r.post('/tts', authMiddleware, async (req, res) => {
const { text, speaker = 1 } = req.body || {};
if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' });
if (text.length > 600) return res.status(400).json({ error: 'text too long (max 600 chars)' });
const cacheKey = `${speaker}:${text}`;
if (ttsCache.has(cacheKey)) {
const cached = ttsCache.get(cacheKey);
res.setHeader('Content-Type', 'audio/wav');
res.setHeader('Content-Length', cached.length);
res.setHeader('X-TTS-Cache', 'HIT');
return res.send(cached);
}
// ユーザー待機フラグを立てる → プリウォームが残り記事をスキップして ttsBusy を解放
userWaiting = true;
const deadline = Date.now() + 30000;
while (ttsBusy && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 200));
}
userWaiting = false;
if (ttsBusy) return res.status(503).json({ error: 'TTS_BUSY' });
try {
ttsBusy = true;
const audioBuffer = await ttsSynthesize(text, speaker);
if (ttsCache.size >= TTS_CACHE_MAX) {
ttsCache.delete(ttsCache.keys().next().value);
}
ttsCache.set(cacheKey, audioBuffer);
res.setHeader('Content-Type', 'audio/wav');
res.setHeader('Content-Length', audioBuffer.length);
res.setHeader('X-TTS-Cache', 'MISS');
res.send(audioBuffer);
} catch (e) {
console.error('[TTS]', e.message);
res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message });
} finally {
ttsBusy = false;
}
});
// POST /tts/ready — 指定テキストがキャッシュ済みか確認(ポーリング用)
r.post('/tts/ready', authMiddleware, (req, res) => {
const { texts, speaker = 1 } = req.body || {};
if (!texts || !Array.isArray(texts)) return res.json({ cached: 0, total: 0, ready: false });
const total = texts.length;
const cached = texts.filter(t => ttsCache.has(`${speaker}:${t}`)).length;
res.json({ cached, total, ready: cached === total });
});
// POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める
// ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。
r.post('/tts/warmup', authMiddleware, async (req, res) => {
const { texts, speaker = 1 } = req.body || {};
if (!texts || !Array.isArray(texts) || texts.length === 0) {
return res.status(400).json({ error: 'texts[] required' });
}
const valid = texts.filter(t => typeof t === 'string' && t.length > 0 && t.length <= 600);
res.status(202).json({ queued: valid.length }); // 即座に返す(合成は行わない)
});
// GET /tts/speakers — 利用可能な話者一覧(デバッグ用)
r.get('/tts/speakers', authMiddleware, async (req, res) => {
try {
const r2 = await fetch(`${VOICEVOX_URL}/speakers`);
const speakers = await r2.json();
res.json(speakers);
} catch (e) {
res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message });
}
});
// Brief クライアントと同じ前処理(キャッシュキーを一致させるため)
function preWarmPreprocess(t) {
return (t || '')
.replace(/https?:\/\/\S+/g, '')
.replace(/[「」『』【】〔〕《》]/g, '')
.replace(/([。!?])([^\s])/g, '$1 $2')
.replace(/\s{2,}/g, ' ')
.trim();
}
// ── サーバー側自動プリウォーム ──────────────────────────────────
// 起動時 + 30分ごとに最新フィード記事の音声を VOICEVOX で合成してキャッシュ
// ユーザーが開いたときは既にキャッシュ済み → 即再生
async function preWarmFeedAudio() {
try {
console.log('[TTS pre-warm] fetching feed...');
const feedRes = await fetch('https://posimai-feed.vercel.app/api/feed', {
signal: AbortSignal.timeout(15000)
});
if (!feedRes.ok) throw new Error(`feed ${feedRes.status}`);
const data = await feedRes.json();
const articles = (data.articles || []).slice(0, 5);
if (articles.length === 0) return;
// ブラウザと同じロジックでテキスト生成Brief の speechQueue + preprocessText と完全一致させる)
const texts = [];
articles.forEach((a, i) => {
const prefix = i === 0 ? '最初のニュースです。' : '続いて。';
texts.push(preWarmPreprocess(`${prefix}${a.source || ''}より。${a.title || ''}`));
});
texts.push(preWarmPreprocess('本日のブリーフィングは以上です。'));
const speaker = 1; // デフォルト: ずんだもん(明るい)
for (const text of texts) {
const cacheKey = `${speaker}:${text}`;
if (ttsCache.has(cacheKey)) {
console.log(`[TTS pre-warm] skip (cached): ${text.substring(0, 25)}`);
continue;
}
if (ttsBusy || userWaiting) {
console.log(`[TTS pre-warm] skip (user waiting): ${text.substring(0, 25)}`);
continue;
}
ttsBusy = true;
try {
const buf = await ttsSynthesize(text, speaker);
if (ttsCache.size >= TTS_CACHE_MAX) ttsCache.delete(ttsCache.keys().next().value);
ttsCache.set(cacheKey, buf);
console.log(`[TTS pre-warm] OK: ${text.substring(0, 25)}`);
} catch (e) {
console.error(`[TTS pre-warm] synth failed: ${e.message}`);
} finally {
ttsBusy = false;
}
}
console.log('[TTS pre-warm] done');
} catch (e) {
console.error('[TTS pre-warm] error:', e.message);
}
}
// 起動直後に実行 + 30分ごとに更新記事が変わっても自動対応
preWarmFeedAudio();
setInterval(preWarmFeedAudio, 30 * 60 * 1000);
// ── IT イベント情報Doorkeeper + connpass RSS ──────────────────────────
r.get('/events/rss', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const [doorkeeper, connpassEvents] = await Promise.allSettled([
fetchDoorkeeper(),
fetchConnpassRss(),
]);
const events = [
...(doorkeeper.status === 'fulfilled' ? doorkeeper.value : []),
...(connpassEvents.status === 'fulfilled' ? connpassEvents.value : []),
];
// URLで重複排除
const seen = new Set();
const unique = events.filter(ev => {
if (seen.has(ev.url)) return false;
seen.add(ev.url);
return true;
});
// 開始日時順にソート
unique.sort((a, b) => (a.startDate + a.startTime).localeCompare(b.startDate + b.startTime));
res.json({ events: unique, fetched_at: new Date().toISOString() });
} catch (err) {
console.error('[events/rss]', err);
res.status(500).json({ events: [], error: err.message });
}
});
// ── Together ──────────────────────────────────────────────────────────────
// invite_code 生成8文字大文字16進
function genInviteCode() {
return crypto.randomBytes(4).toString('hex').toUpperCase();
}
// fire-and-forget アーカイブ: Jina Reader → Gemini 要約(直列)
async function archiveShare(shareId, url) {
if (!url) {
await pool.query(`UPDATE together_shares SET archive_status='failed' WHERE id=$1`, [shareId]);
return;
}
try {
const jinaRes = await fetch(`https://r.jina.ai/${url}`, {
headers: { 'Accept': 'text/plain', 'User-Agent': 'Posimai/1.0' },
signal: AbortSignal.timeout(30000),
});
if (!jinaRes.ok) throw new Error(`Jina ${jinaRes.status}`);
const fullContent = await jinaRes.text();
// Jina Reader のレスポンス先頭から "Title: ..." を抽出
const titleMatch = fullContent.match(/^Title:\s*(.+)/m);
const jinaTitle = titleMatch ? titleMatch[1].trim().slice(0, 300) : null;
await pool.query(
`UPDATE together_shares SET full_content=$1, title=COALESCE(title, $2) WHERE id=$3`,
[fullContent, jinaTitle, shareId]
);
let summary = null;
let tags = [];
if (genAI && fullContent) {
// 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す
const bodyStart = fullContent.search(/^#{1,2}\s/m);
const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000);
const model = genAITogether.getGenerativeModel({ model: 'gemini-2.5-flash' });
const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと。内容が不明な場合でも最も近いカテゴリを選ぶ例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン, ライフスタイル, 教育, 環境, 医療, 法律, 文化, 歴史)\n\n記事:\n${excerpt}`;
const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000));
const result = await Promise.race([model.generateContent(prompt), timeoutP]);
const raw = result.response.text().trim();
try {
const parsed = JSON.parse(raw);
summary = (parsed.summary || '').slice(0, 300);
tags = Array.isArray(parsed.tags) ? parsed.tags.slice(0, 4).map(t => String(t).slice(0, 20)) : [];
} catch {
// JSON パース失敗時は全文を要約として扱う
summary = raw.slice(0, 300);
}
}
await pool.query(
`UPDATE together_shares SET summary=$1, tags=$2, archive_status='done' WHERE id=$3`,
[summary, tags, shareId]
);
} catch (e) {
console.error('[together archive]', shareId, e.message);
await pool.query(`UPDATE together_shares SET archive_status='failed' WHERE id=$1`, [shareId]);
}
}
// POST /together/groups — グループ作成
r.post('/together/groups', async (req, res) => {
const { name, username } = req.body || {};
if (!name || !username) return res.status(400).json({ error: 'name と username は必須です' });
try {
let invite_code, attempt = 0;
while (attempt < 5) {
invite_code = genInviteCode();
const exists = await pool.query('SELECT id FROM together_groups WHERE invite_code=$1', [invite_code]);
if (exists.rows.length === 0) break;
attempt++;
}
const group = await pool.query(
`INSERT INTO together_groups (name, invite_code) VALUES ($1, $2) RETURNING *`,
[name, invite_code]
);
await pool.query(
`INSERT INTO together_members (group_id, username) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[group.rows[0].id, username]
);
res.json({ group: group.rows[0] });
} catch (e) {
console.error('[together/groups POST]', e.message);
res.status(500).json({ error: 'グループ作成に失敗しました' });
}
});
// POST /together/join — 招待コードでグループ参加
r.post('/together/join', async (req, res) => {
const { invite_code, username } = req.body || {};
if (!invite_code || !username) return res.status(400).json({ error: 'invite_code と username は必須です' });
try {
const group = await pool.query(
'SELECT * FROM together_groups WHERE invite_code=$1',
[invite_code.toUpperCase()]
);
if (group.rows.length === 0) return res.status(404).json({ error: '招待コードが見つかりません' });
await pool.query(
`INSERT INTO together_members (group_id, username) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[group.rows[0].id, username]
);
res.json({ group: group.rows[0] });
} catch (e) {
console.error('[together/join]', e.message);
res.status(500).json({ error: 'グループ参加に失敗しました' });
}
});
// GET /together/groups/:groupId — グループ情報
r.get('/together/groups/:groupId', async (req, res) => {
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' });
try {
const result = await pool.query('SELECT * FROM together_groups WHERE id=$1', [req.params.groupId]);
if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
res.json(result.rows[0]);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// 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: e.message });
}
});
// POST /together/share — 記事・テキストをシェア(即返却 + 非同期アーカイブ)
r.post('/together/share', async (req, res) => {
const { group_id, shared_by, url = null, title = null, message = '', tags = [] } = req.body || {};
if (!group_id || !shared_by) return res.status(400).json({ error: 'group_id と shared_by は必須です' });
if (url) {
try { const p = new URL(url); if (!['http:', 'https:'].includes(p.protocol)) throw new Error(); }
catch { return res.status(400).json({ error: 'url は http/https のみ有効です' }); }
}
try {
const grpCheck = await pool.query('SELECT id FROM together_groups WHERE id=$1', [group_id]);
if (grpCheck.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
const result = await pool.query(
`INSERT INTO together_shares (group_id, shared_by, url, title, message, tags)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[group_id, shared_by, url, title, message, tags]
);
const share = result.rows[0];
res.json({ ok: true, share });
// URL がある場合のみ非同期アーカイブ(ユーザーを待たせない)
if (url) archiveShare(share.id, url);
} catch (e) {
console.error('[together/share]', e.message);
res.status(500).json({ error: e.message });
}
});
// DELETE /together/share/:id — 自分の投稿を削除
r.delete('/together/share/:id', async (req, res) => {
const { username } = req.body || {};
if (!username) return res.status(400).json({ error: 'username は必須です' });
try {
const result = await pool.query(
'DELETE FROM together_shares WHERE id=$1 AND shared_by=$2 RETURNING id',
[req.params.id, username]
);
if (result.rows.length === 0) return res.status(403).json({ error: '削除できません' });
res.json({ ok: true });
} catch (e) {
console.error('[together/share DELETE]', e.message);
res.status(500).json({ error: e.message });
}
});
// 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: e.message });
}
});
// 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: e.message });
}
});
// 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 {
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: e.message });
}
});
// 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: e.message });
}
});
// POST /together/comments — コメント投稿
r.post('/together/comments', async (req, res) => {
const { share_id, username, body } = req.body || {};
if (!share_id || !username || !body?.trim()) {
return res.status(400).json({ error: 'share_id, username, body は必須です' });
}
try {
const result = await pool.query(
'INSERT INTO together_comments (share_id, username, body) VALUES ($1, $2, $3) RETURNING *',
[share_id, username, body.trim()]
);
res.json(result.rows[0]);
} catch (e) {
console.error('[together/comments POST]', e.message);
res.status(500).json({ error: e.message });
}
});
// 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: e.message });
}
});
// ── Atlas: GitHub scan proxy ───────────────────────────────────
r.get('/atlas/github-scan', (req, res) => {
const token = req.query.token;
const org = req.query.org || '';
if (!token) return res.status(400).json({ error: 'token required' });
const https = require('https');
function ghRequest(path, cb) {
const options = {
hostname: 'api.github.com',
path,
method: 'GET',
family: 4,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'User-Agent': 'Posimai-Atlas/1.0',
'X-GitHub-Api-Version': '2022-11-28',
},
timeout: 12000,
};
const r2 = https.request(options, (resp) => {
let body = '';
resp.on('data', chunk => { body += chunk; });
resp.on('end', () => cb(null, resp.statusCode, body));
});
r2.on('timeout', () => { r2.destroy(); cb(new Error('Timeout')); });
r2.on('error', cb);
r2.end();
}
const orgPath = org ? `/orgs/${encodeURIComponent(org)}/repos?per_page=100&sort=updated` : null;
const userPath = `/user/repos?per_page=100&sort=updated&affiliation=owner`;
function handleResult(status, body) {
if (status !== 200) return res.status(status).json({ error: body });
try { res.json(JSON.parse(body)); }
catch (e) { res.status(500).json({ error: 'Invalid JSON' }); }
}
if (orgPath) {
ghRequest(orgPath, (err, status, body) => {
if (err) return res.status(500).json({ error: err.message });
// If org not accessible, fall back to user repos
if (status === 404 || status === 403) {
ghRequest(userPath, (err2, status2, body2) => {
if (err2) return res.status(500).json({ error: err2.message });
// Signal to client that we fell back
if (status2 === 200) {
try {
const data = JSON.parse(body2);
return res.json({ repos: data, fallback: true });
} catch (e) { return res.status(500).json({ error: 'Invalid JSON' }); }
}
handleResult(status2, body2);
});
} else {
handleResult(status, body);
}
});
} else {
ghRequest(userPath, (err, status, body) => {
if (err) return res.status(500).json({ error: err.message });
handleResult(status, body);
});
}
});
// ── Atlas: Vercel scan proxy ───────────────────────────────────
r.get('/atlas/vercel-scan', (req, res) => {
const token = req.query.token;
if (!token) return res.status(400).json({ error: 'token required' });
const https = require('https');
const options = {
hostname: 'api.vercel.com',
path: '/v9/projects?limit=100',
method: 'GET',
family: 4,
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': 'Posimai-Atlas/1.0',
},
timeout: 12000,
};
const req2 = https.request(options, (r2) => {
let body = '';
r2.on('data', chunk => { body += chunk; });
r2.on('end', () => {
if (r2.statusCode !== 200) return res.status(r2.statusCode).json({ error: body });
try { res.json(JSON.parse(body)); }
catch (e) { res.status(500).json({ error: 'Invalid JSON' }); }
});
});
req2.on('timeout', () => { req2.destroy(); res.status(500).json({ error: 'Timeout' }); });
req2.on('error', (e) => { res.status(500).json({ error: e.message, code: e.code }); });
req2.end();
});
// ── Atlas: Tailscale scan proxy ────────────────────────────────
r.get('/atlas/tailscale-scan', (req, res) => {
const token = req.query.token;
if (!token) return res.status(400).json({ error: 'token required' });
const https = require('https');
const options = {
hostname: 'api.tailscale.com',
path: '/api/v2/tailnet/-/devices',
method: 'GET',
family: 4, // force IPv4; container IPv6 to tailscale times out
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'User-Agent': 'Posimai-Atlas/1.0',
},
timeout: 12000,
};
const req2 = https.request(options, (r2) => {
let body = '';
r2.on('data', chunk => { body += chunk; });
r2.on('end', () => {
if (r2.statusCode !== 200) {
return res.status(r2.statusCode).json({ error: body });
}
try {
res.json(JSON.parse(body));
} catch (e) {
res.status(500).json({ error: 'Invalid JSON from Tailscale' });
}
});
});
req2.on('timeout', () => {
req2.destroy();
res.status(500).json({ error: 'Request timed out' });
});
req2.on('error', (e) => {
console.error('[atlas/tailscale-scan] error:', e.code, e.message);
res.status(500).json({ error: e.message, 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));
// ── マウントTailscale経由と直接アクセスの両方対応
const router = buildRouter();
app.use('/brain/api', router); // Tailscale Funnel 経由
app.use('/api', router); // ローカル直接アクセス
// ── 起動 ──────────────────────────────────
const PORT = parseInt(process.env.PORT || '8090');
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`);
});
})
.catch(err => {
console.error('[FATAL] Startup failed:', err.message);
process.exit(1);
});