3212 lines
149 KiB
JavaScript
3212 lines
149 KiB
JavaScript
// ============================================
|
||
// Posimai Brain API — Synology Docker Server
|
||
// ============================================
|
||
// Port: 8090
|
||
// Route prefix: /brain/api (Tailscale Funnel) and /api (local dev)
|
||
//
|
||
// ENV VARS (set in docker-compose.yml):
|
||
// DB_HOST, DB_PORT, DB_USER, DB_PASSWORD
|
||
// GEMINI_API_KEY
|
||
// API_KEYS = "pk_maita_xxx:maita,pk_partner_xxx:partner,pk_musume_xxx:musume"
|
||
// ALLOWED_ORIGINS = 追加許可したいオリジン(カンマ区切り)※ posimai-*.vercel.app は自動許可
|
||
// ============================================
|
||
|
||
const express = require('express');
|
||
const cors = require('cors');
|
||
const { Pool } = require('pg');
|
||
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||
const { parse } = require('node-html-parser');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
const jwt = require('jsonwebtoken');
|
||
const os = require('os');
|
||
const { execSync } = require('child_process');
|
||
let RssParser = null;
|
||
try { RssParser = require('rss-parser'); } catch (_) { console.warn('[Feed] rss-parser not found, background fetch disabled'); }
|
||
|
||
// ── Auth: WebAuthn (ESM dynamic import) ─────────────────────────────
|
||
let webauthn = null;
|
||
async function loadWebauthn() {
|
||
try {
|
||
webauthn = await import('@simplewebauthn/server');
|
||
console.log('[Auth] SimpleWebAuthn loaded');
|
||
} catch (e) {
|
||
console.warn('[Auth] SimpleWebAuthn not available:', e.message);
|
||
}
|
||
}
|
||
|
||
// In-memory WebAuthn challenge store (TTL: 5 min)
|
||
const webauthnChallenges = new Map();
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [k, v] of webauthnChallenges) {
|
||
if (v.expiresAt < now) webauthnChallenges.delete(k);
|
||
}
|
||
}, 10 * 60 * 1000);
|
||
|
||
// ── 汎用インメモリレートリミッター ──────────────────────────────
|
||
// usage: checkRateLimit(store, key, maxCount, windowMs)
|
||
// 返り値: true = 制限内、false = 超過
|
||
const rateLimitStores = {};
|
||
function checkRateLimit(storeName, key, maxCount, windowMs) {
|
||
if (!rateLimitStores[storeName]) rateLimitStores[storeName] = new Map();
|
||
const store = rateLimitStores[storeName];
|
||
const now = Date.now();
|
||
const entry = store.get(key);
|
||
if (!entry || now - entry.windowStart >= windowMs) {
|
||
store.set(key, { count: 1, windowStart: now });
|
||
return true;
|
||
}
|
||
if (entry.count >= maxCount) return false;
|
||
entry.count++;
|
||
return true;
|
||
}
|
||
// 定期クリーンアップ(1時間ごと)
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const store of Object.values(rateLimitStores)) {
|
||
for (const [k, v] of store) {
|
||
if (now - v.windowStart > 60 * 60 * 1000) store.delete(k);
|
||
}
|
||
}
|
||
}, 60 * 60 * 1000);
|
||
|
||
// ── ユーティリティ ───────────────────────────────────────────────────
|
||
function escapeHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// ── 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;
|
||
|
||
|
||
// ── 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, _retry = false) {
|
||
if (!genAI) return null;
|
||
try {
|
||
const model = genAI.getGenerativeModel({
|
||
model: 'gemini-2.5-flash',
|
||
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());
|
||
}
|
||
// 503(一時的高負荷)は1回だけリトライ
|
||
if (!_retry && e.status === 503) {
|
||
console.warn('[Gemini] 503 detected, retrying in 4s...');
|
||
await new Promise(r => setTimeout(r, 4000));
|
||
return analyzeWithGemini(title, fullText, url, true);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── 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`,
|
||
|
||
// Together: メンバーと Posimai ログインユーザーの紐付け(JWT 強化用・既存 PK は維持)
|
||
`ALTER TABLE together_members ADD COLUMN IF NOT EXISTS user_id VARCHAR(50)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_together_members_user_id ON together_members(user_id) WHERE user_id IS NOT NULL`,
|
||
// username = users.user_id または users.name と一致する行のみ自動紐付け(冪等)
|
||
`UPDATE together_members tm SET user_id = u.user_id FROM users u
|
||
WHERE tm.user_id IS NULL AND (tm.username = u.user_id OR tm.username = u.name)`,
|
||
];
|
||
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(', '));
|
||
}
|
||
|
||
// ── Together: 任意 JWT(Authorization のみ。GET の query key は使わない)────────
|
||
function getOptionalJwtUserIdFromHeader(req) {
|
||
const auth = req.headers.authorization || '';
|
||
if (!auth.toLowerCase().startsWith('bearer ')) return null;
|
||
const token = auth.substring(7).trim();
|
||
if (!token || token.startsWith('pk_') || !token.includes('.')) return null;
|
||
try {
|
||
const payload = jwt.verify(token, JWT_SECRET);
|
||
return payload.userId || null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getTogetherJwtUserId(req) {
|
||
if (process.env.TOGETHER_DISABLE_JWT === '1' || /^true$/i.test(process.env.TOGETHER_DISABLE_JWT || '')) {
|
||
return null;
|
||
}
|
||
return getOptionalJwtUserIdFromHeader(req);
|
||
}
|
||
|
||
/**
|
||
* Together メンバー確認。JWT なしのときは従来どおり username のみ(完全互換)。
|
||
* JWT ありのときは user_id 一致、または未紐付けメンバーで username が JWT ユーザーの user_id/name と一致する場合は厳格一致。
|
||
* それ以外の未紐付けニックネームは従来クエリで許可し warn ログ(運用互換)。
|
||
*/
|
||
async function togetherEnsureMember(pool, res, groupId, username, jwtUserId) {
|
||
if (!username || typeof username !== 'string') {
|
||
res.status(400).json({ error: 'username が不正です' });
|
||
return false;
|
||
}
|
||
const gidNum = parseInt(String(groupId), 10);
|
||
if (Number.isNaN(gidNum)) {
|
||
res.status(400).json({ error: 'invalid groupId' });
|
||
return false;
|
||
}
|
||
try {
|
||
if (jwtUserId) {
|
||
const strict = await pool.query(
|
||
`SELECT 1 FROM together_members m
|
||
WHERE m.group_id = $1 AND (
|
||
m.user_id = $2
|
||
OR (
|
||
(m.user_id IS NULL OR btrim(COALESCE(m.user_id, '')) = '')
|
||
AND m.username = $3
|
||
AND EXISTS (
|
||
SELECT 1 FROM users u
|
||
WHERE u.user_id = $2 AND (u.user_id = $3 OR u.name = $3)
|
||
)
|
||
)
|
||
)`,
|
||
[gidNum, jwtUserId, username]
|
||
);
|
||
if (strict.rows.length > 0) return true;
|
||
|
||
const legacy = await pool.query(
|
||
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
||
[gidNum, username]
|
||
);
|
||
if (legacy.rows.length > 0) {
|
||
// user_id 未紐付け期間の暫定: メンバー行があれば許可(紐付け完了後に削除予定)
|
||
console.warn('[Together] legacy path used user=%s username=%s group=%s', jwtUserId, username, gidNum);
|
||
return true;
|
||
}
|
||
res.status(403).json({ error: 'グループのメンバーではありません' });
|
||
return false;
|
||
}
|
||
const legacyOnly = await pool.query(
|
||
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2',
|
||
[gidNum, username]
|
||
);
|
||
if (legacyOnly.rows.length === 0) {
|
||
res.status(403).json({ error: 'グループのメンバーではありません' });
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (e) {
|
||
console.error('[Together] togetherEnsureMember', e.message);
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** share_id 経由で group を解決し、username がそのグループのメンバーか(JWT 強化込み) */
|
||
async function togetherEnsureMemberForShare(pool, res, shareId, username, jwtUserId) {
|
||
if (!username || typeof username !== 'string') {
|
||
res.status(400).json({ error: 'username が不正です' });
|
||
return false;
|
||
}
|
||
try {
|
||
const g = await pool.query('SELECT group_id FROM together_shares WHERE id=$1', [shareId]);
|
||
if (g.rows.length === 0) {
|
||
res.status(404).json({ error: '見つかりません' });
|
||
return false;
|
||
}
|
||
return togetherEnsureMember(pool, res, g.rows[0].group_id, username, jwtUserId);
|
||
} catch (e) {
|
||
console.error('[Together] togetherEnsureMemberForShare', e.message);
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── ルーター ──────────────────────────────
|
||
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 }, { rows: countRows }] = await Promise.all([
|
||
pool.query(sql, params),
|
||
pool.query(
|
||
`SELECT status, COUNT(*)::int AS cnt FROM articles WHERE user_id=$1 GROUP BY status`,
|
||
[req.userId]
|
||
)
|
||
]);
|
||
|
||
const countMap = Object.fromEntries(countRows.map(r => [r.status, r.cnt]));
|
||
const counts = {
|
||
all: countRows.reduce((s, r) => s + r.cnt, 0),
|
||
unread: countMap['inbox'] || 0,
|
||
favorite: countMap['favorite'] || 0,
|
||
shared: countMap['shared'] || 0,
|
||
};
|
||
|
||
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) => {
|
||
if (!ai) {
|
||
console.warn(`[Brain API] AI analysis failed for ${url}, clearing placeholder`);
|
||
await pool.query(
|
||
`UPDATE articles SET summary=NULL WHERE user_id=$1 AND url=$2 AND summary LIKE '⏳%'`,
|
||
[savedUserId, url]
|
||
);
|
||
return;
|
||
}
|
||
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 || e);
|
||
}
|
||
});
|
||
|
||
} 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) => {
|
||
if (!ai) {
|
||
await pool.query(
|
||
`UPDATE articles SET summary=NULL WHERE user_id=$1 AND url=$2 AND summary LIKE '⏳%'`,
|
||
[savedUserId, url]
|
||
);
|
||
return;
|
||
}
|
||
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>保存に失敗しました</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.5-flash',
|
||
generationConfig: { responseMimeType: 'application/json' }
|
||
});
|
||
const excerpt = smartExtract(body, 2000);
|
||
const prompt = `以下の記事にふさわしい日本語タグを3〜5個提案してください。
|
||
タグは短い単語または短いフレーズ(例: "Next.js", "インフラ", "開発メモ", "Tailscale")。
|
||
既存のタグと重複してもOK。結果はJSONで返してください。
|
||
|
||
タイトル: ${title.slice(0, 80)}
|
||
本文抜粋:
|
||
${excerpt}
|
||
|
||
{"tags":["タグ1","タグ2","タグ3"]}`;
|
||
|
||
const timeoutPromise = new Promise((_, reject) =>
|
||
setTimeout(() => reject(new Error('timeout')), 12000)
|
||
);
|
||
const result = await Promise.race([model.generateContent(prompt), timeoutPromise]);
|
||
let raw = result.response.text().trim()
|
||
.replace(/^```(json)?/i, '').replace(/```$/i, '').trim();
|
||
const parsed = JSON.parse(raw);
|
||
const tags = (parsed.tags || []).filter(t => typeof t === 'string' && t.length <= 30).slice(0, 5);
|
||
res.json({ tags });
|
||
} catch (e) {
|
||
console.error('[suggest-tags]', e.message);
|
||
res.status(500).json({ error: 'AI suggestion failed' });
|
||
}
|
||
});
|
||
|
||
r.post('/journal/upload', authMiddleware, (req, res) => {
|
||
try {
|
||
const { base64 } = req.body || {};
|
||
if (!base64) return res.status(400).json({ error: 'base64 required' });
|
||
|
||
const match = base64.match(/^data:(image\/(jpeg|png|gif|webp));base64,(.+)$/);
|
||
if (!match) return res.status(400).json({ error: 'Invalid image format' });
|
||
|
||
const ext = match[2] === 'jpeg' ? 'jpg' : match[2];
|
||
const buffer = Buffer.from(match[3], 'base64');
|
||
if (buffer.length > 5 * 1024 * 1024) {
|
||
return res.status(400).json({ error: 'File too large (max 5MB)' });
|
||
}
|
||
|
||
const name = crypto.randomBytes(10).toString('hex') + '.' + ext;
|
||
fs.writeFileSync(path.join(UPLOADS_DIR, name), buffer);
|
||
|
||
const url = `https://api.soar-enrich.com/brain/api/uploads/${name}`;
|
||
res.json({ ok: true, url });
|
||
} catch (e) {
|
||
console.error('[Upload Error]:', e);
|
||
res.status(500).json({ error: 'Upload failed' });
|
||
}
|
||
});
|
||
|
||
// ── Habit API ─────────────────────────────
|
||
// GET /habit/habits — habit 一覧取得
|
||
r.get('/habit/habits', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT id, name, icon, position FROM habit_habits WHERE user_id=$1 ORDER BY position, id',
|
||
[req.userId]
|
||
);
|
||
res.json({ habits: rows });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// POST /habit/habits — habit 作成
|
||
r.post('/habit/habits', authMiddleware, async (req, res) => {
|
||
const { name, icon = 'check', position = 0 } = req.body || {};
|
||
if (!name) return res.status(400).json({ error: 'name required' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO habit_habits (user_id, name, icon, position) VALUES ($1,$2,$3,$4) RETURNING id, name, icon, position',
|
||
[req.userId, name, icon, position]
|
||
);
|
||
res.json({ habit: rows[0] });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// PATCH /habit/habits/:id — habit 更新(name / icon / position)
|
||
r.patch('/habit/habits/:id', authMiddleware, async (req, res) => {
|
||
const { name, icon, position } = req.body || {};
|
||
try {
|
||
const sets = [];
|
||
const params = [];
|
||
let i = 1;
|
||
if (name !== undefined) { sets.push(`name=$${i++}`); params.push(name); }
|
||
if (icon !== undefined) { sets.push(`icon=$${i++}`); params.push(icon); }
|
||
if (position !== undefined) { sets.push(`position=$${i++}`); params.push(position); }
|
||
if (sets.length === 0) return res.status(400).json({ error: 'nothing to update' });
|
||
params.push(req.params.id, req.userId);
|
||
const { rows } = await pool.query(
|
||
`UPDATE habit_habits SET ${sets.join(',')} WHERE id=$${i++} AND user_id=$${i} RETURNING id, name, icon, position`,
|
||
params
|
||
);
|
||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||
res.json({ habit: rows[0] });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// DELETE /habit/habits/:id — habit 削除
|
||
r.delete('/habit/habits/:id', authMiddleware, async (req, res) => {
|
||
try {
|
||
await pool.query(
|
||
'DELETE FROM habit_habits WHERE id=$1 AND user_id=$2',
|
||
[req.params.id, req.userId]
|
||
);
|
||
res.json({ ok: true });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// GET /habit/log/:date — その日のチェック済み habit_id 一覧
|
||
r.get('/habit/log/:date', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT habit_id FROM habit_log WHERE user_id=$1 AND log_date=$2',
|
||
[req.userId, req.params.date]
|
||
);
|
||
res.json({ checked: rows.map(r => r.habit_id) });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// POST /habit/log/:date — habit をトグル(checked=true で追加、false で削除)
|
||
r.post('/habit/log/:date', authMiddleware, async (req, res) => {
|
||
const { habitId, checked } = req.body || {};
|
||
if (!habitId) return res.status(400).json({ error: 'habitId required' });
|
||
try {
|
||
if (checked) {
|
||
await pool.query(
|
||
'INSERT INTO habit_log (user_id, habit_id, log_date) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
|
||
[req.userId, habitId, req.params.date]
|
||
);
|
||
} else {
|
||
await pool.query(
|
||
'DELETE FROM habit_log WHERE user_id=$1 AND habit_id=$2 AND log_date=$3',
|
||
[req.userId, habitId, req.params.date]
|
||
);
|
||
}
|
||
res.json({ ok: true });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// GET /habit/heatmap — 過去 N 日分のチェック数(ヒートマップ用)
|
||
r.get('/habit/heatmap', authMiddleware, async (req, res) => {
|
||
const days = Math.min(parseInt(req.query.days || '90') || 90, 365);
|
||
try {
|
||
const { rows } = await pool.query(`
|
||
SELECT log_date::text AS date, COUNT(*) AS count
|
||
FROM habit_log
|
||
WHERE user_id=$1 AND log_date >= CURRENT_DATE - ($2 || ' days')::INTERVAL
|
||
GROUP BY log_date ORDER BY log_date
|
||
`, [req.userId, days]);
|
||
res.json({ heatmap: rows });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// ── Pulse API ─────────────────────────────
|
||
// GET /pulse/log/:date — 特定日のデータ取得
|
||
r.get('/pulse/log/:date', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT mood, energy, focus, note FROM pulse_log WHERE user_id=$1 AND log_date=$2',
|
||
[req.userId, req.params.date]
|
||
);
|
||
res.json({ entry: rows[0] || null });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// POST /pulse/log/:date — 記録(UPSERT)
|
||
r.post('/pulse/log/:date', authMiddleware, async (req, res) => {
|
||
const { mood, energy, focus, note = '' } = req.body || {};
|
||
if (!mood && !energy && !focus) return res.status(400).json({ error: 'at least one metric required' });
|
||
try {
|
||
const { rows } = await pool.query(`
|
||
INSERT INTO pulse_log (user_id, log_date, mood, energy, focus, note, updated_at)
|
||
VALUES ($1,$2,$3,$4,$5,$6,NOW())
|
||
ON CONFLICT (user_id, log_date) DO UPDATE
|
||
SET mood=COALESCE($3, pulse_log.mood),
|
||
energy=COALESCE($4, pulse_log.energy),
|
||
focus=COALESCE($5, pulse_log.focus),
|
||
note=COALESCE(NULLIF($6,''), pulse_log.note),
|
||
updated_at=NOW()
|
||
RETURNING mood, energy, focus, note
|
||
`, [req.userId, req.params.date, mood || null, energy || null, focus || null, note]);
|
||
res.json({ entry: rows[0] });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// GET /pulse/log — 範囲取得(デフォルト直近30日)
|
||
r.get('/pulse/log', authMiddleware, async (req, res) => {
|
||
const days = Math.min(parseInt(req.query.days || '30') || 30, 365);
|
||
try {
|
||
const { rows } = await pool.query(`
|
||
SELECT log_date::text AS date, mood, energy, focus, note
|
||
FROM pulse_log
|
||
WHERE user_id=$1 AND log_date >= CURRENT_DATE - ($2 || ' days')::INTERVAL
|
||
ORDER BY log_date
|
||
`, [req.userId, days]);
|
||
res.json({ entries: rows });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// ── Lens API ──────────────────────────────
|
||
// GET /lens/history — スキャン履歴取得(直近 limit 件)
|
||
r.get('/lens/history', authMiddleware, async (req, res) => {
|
||
const limit = Math.min(parseInt(req.query.limit || '20') || 20, 100);
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'SELECT id, filename, exif_data, thumbnail, scanned_at FROM lens_history WHERE user_id=$1 ORDER BY scanned_at DESC LIMIT $2',
|
||
[req.userId, limit]
|
||
);
|
||
res.json({ history: rows });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// POST /lens/history — スキャン結果を保存
|
||
r.post('/lens/history', authMiddleware, async (req, res) => {
|
||
const { filename, exif_data, thumbnail } = req.body || {};
|
||
if (!exif_data) return res.status(400).json({ error: 'exif_data required' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
'INSERT INTO lens_history (user_id, filename, exif_data, thumbnail) VALUES ($1,$2,$3,$4) RETURNING id, scanned_at',
|
||
[req.userId, filename || null, JSON.stringify(exif_data), thumbnail || null]
|
||
);
|
||
res.json({ ok: true, id: rows[0].id, scanned_at: rows[0].scanned_at });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// DELETE /lens/history/:id — 履歴削除
|
||
r.delete('/lens/history/:id', authMiddleware, async (req, res) => {
|
||
try {
|
||
await pool.query(
|
||
'DELETE FROM lens_history WHERE id=$1 AND user_id=$2',
|
||
[req.params.id, req.userId]
|
||
);
|
||
res.json({ ok: true });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// ── Feed Media(カスタムRSSソース管理)────────────────────────────
|
||
// テーブル: feed_media (id SERIAL PK, user_id TEXT, name TEXT, feed_url TEXT,
|
||
// site_url TEXT, category TEXT, is_active BOOLEAN DEFAULT true,
|
||
// created_at TIMESTAMPTZ DEFAULT NOW())
|
||
|
||
r.get('/feed/media', authMiddleware, async (req, res) => {
|
||
try {
|
||
// default_user のメディア(共通)+ ユーザー自身のメディアを統合して返す
|
||
const result = await pool.query(
|
||
`SELECT id, name, feed_url, site_url, category, is_active, created_at,
|
||
(user_id = 'default_user') AS is_default
|
||
FROM feed_media
|
||
WHERE user_id = 'default_user' OR user_id = $1
|
||
ORDER BY is_default DESC, created_at ASC`,
|
||
[req.userId]
|
||
);
|
||
res.json(result.rows);
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
r.post('/feed/media', authMiddleware, async (req, res) => {
|
||
const { name, feed_url, site_url = '', is_active = true } = req.body || {};
|
||
if (!name || !feed_url) return res.status(400).json({ error: 'name and feed_url required' });
|
||
// カテゴリ自動判定(指定があればそのまま使用)
|
||
let category = req.body.category || '';
|
||
if (!category) {
|
||
const urlLower = feed_url.toLowerCase();
|
||
if (/news|nhk|yahoo|nikkei|asahi|mainichi|yomiuri/.test(urlLower)) category = 'news';
|
||
else if (/business|bizjapan|diamond|toyo|kaizen/.test(urlLower)) category = 'business';
|
||
else if (/lifestyle|life|food|cooking|fashion|beauty|travel/.test(urlLower)) category = 'lifestyle';
|
||
else category = 'tech';
|
||
}
|
||
try {
|
||
const result = await pool.query(
|
||
'INSERT INTO feed_media (user_id, name, feed_url, site_url, category, is_active) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, name, feed_url, site_url, category, is_active, created_at',
|
||
[req.userId, name, feed_url, site_url, category, is_active]
|
||
);
|
||
res.status(201).json(result.rows[0]);
|
||
} catch (e) {
|
||
if (e.code === '23505') return res.status(409).json({ error: 'このメディアはすでに追加済みです' });
|
||
console.error(e); res.status(500).json({ error: 'DB error' });
|
||
}
|
||
});
|
||
|
||
r.patch('/feed/media/:id', authMiddleware, async (req, res) => {
|
||
const { name, feed_url, site_url, category, is_active } = req.body || {};
|
||
try {
|
||
const fields = [];
|
||
const vals = [];
|
||
let idx = 1;
|
||
if (name !== undefined) { fields.push(`name=$${idx++}`); vals.push(name); }
|
||
if (feed_url !== undefined) { fields.push(`feed_url=$${idx++}`); vals.push(feed_url); }
|
||
if (site_url !== undefined) { fields.push(`site_url=$${idx++}`); vals.push(site_url); }
|
||
if (category !== undefined) { fields.push(`category=$${idx++}`); vals.push(category); }
|
||
if (is_active !== undefined) { fields.push(`is_active=$${idx++}`); vals.push(is_active); }
|
||
if (fields.length === 0) return res.status(400).json({ error: 'no fields to update' });
|
||
vals.push(req.params.id, req.userId);
|
||
const result = await pool.query(
|
||
`UPDATE feed_media SET ${fields.join(',')} WHERE id=$${idx} AND user_id=$${idx + 1} RETURNING id, name, feed_url, site_url, category, is_active`,
|
||
vals
|
||
);
|
||
if (result.rowCount === 0) return res.status(404).json({ error: 'not found' });
|
||
res.json(result.rows[0]);
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
r.delete('/feed/media/:id', authMiddleware, async (req, res) => {
|
||
try {
|
||
await pool.query(
|
||
'DELETE FROM feed_media WHERE id=$1 AND user_id=$2',
|
||
[req.params.id, req.userId]
|
||
);
|
||
res.json({ ok: true });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// ── Feed Articles(DBキャッシュから高速配信)──────────────────────
|
||
// VPS背景取得ジョブがfeed_articlesテーブルに書き込み、ここで読む
|
||
r.get('/feed/articles', authMiddleware, async (req, res) => {
|
||
try {
|
||
const limit = Math.min(parseInt(req.query.limit || '100'), 200);
|
||
const offset = parseInt(req.query.offset || '0');
|
||
const category = req.query.category || null;
|
||
const mediaId = req.query.media_id || null;
|
||
|
||
let where = `WHERE fa.user_id IN ('default_user', $1)`;
|
||
const vals = [req.userId];
|
||
let idx = 2;
|
||
|
||
if (category) { where += ` AND fm.category = $${idx++}`; vals.push(category); }
|
||
if (mediaId) { where += ` AND fa.media_id = $${idx++}`; vals.push(mediaId); }
|
||
|
||
const [articlesResult, mediasResult] = await Promise.all([
|
||
pool.query(
|
||
`SELECT fa.id, fa.url, fa.title, fa.summary, fa.author,
|
||
fa.published_at, fa.is_read,
|
||
fm.id AS media_id, fm.name AS source, fm.category,
|
||
fm.feed_url, fm.site_url, fm.favicon
|
||
FROM feed_articles fa
|
||
JOIN feed_media fm ON fa.media_id = fm.id
|
||
${where}
|
||
ORDER BY fa.published_at DESC NULLS LAST
|
||
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||
[...vals, limit, offset]
|
||
),
|
||
pool.query(
|
||
`SELECT id, name, feed_url, site_url, category, favicon,
|
||
(user_id = 'default_user') AS is_default
|
||
FROM feed_media
|
||
WHERE user_id IN ('default_user', $1) AND is_active = true
|
||
ORDER BY is_default DESC, created_at ASC`,
|
||
[req.userId]
|
||
)
|
||
]);
|
||
|
||
res.json({
|
||
success: true,
|
||
articles: articlesResult.rows,
|
||
medias: mediasResult.rows,
|
||
categories: [
|
||
{ id: 'all', name: '全て', icon: 'layout-grid' },
|
||
{ id: 'news', name: 'ニュース', icon: 'newspaper' },
|
||
{ id: 'tech', name: 'テクノロジー', icon: 'rocket' },
|
||
{ id: 'lifestyle', name: 'ライフスタイル', icon: 'coffee' },
|
||
{ id: 'business', name: 'ビジネス', icon: 'briefcase' },
|
||
],
|
||
total: articlesResult.rows.length,
|
||
updatedAt: new Date().toISOString()
|
||
});
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// Feed既読マーク
|
||
r.patch('/feed/articles/:id/read', authMiddleware, async (req, res) => {
|
||
try {
|
||
await pool.query(
|
||
`UPDATE feed_articles SET is_read = true WHERE id = $1 AND user_id IN ('default_user', $2)`,
|
||
[req.params.id, req.userId]
|
||
);
|
||
res.json({ ok: true });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
|
||
});
|
||
|
||
// 手動リフレッシュ(フロントの更新ボタンから呼ぶ)
|
||
r.post('/feed/refresh', authMiddleware, async (req, res) => {
|
||
try {
|
||
const count = await runFeedFetch();
|
||
res.json({ ok: true, fetched: count });
|
||
} catch (e) { console.error(e); res.status(500).json({ error: 'Internal server error' }); }
|
||
});
|
||
|
||
// ── TTS (VOICEVOX) ─────────────────────────────────────────────
|
||
// VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021)
|
||
// 未設定時は localhost フォールバック(NAS上で同じコンテナホストの場合)
|
||
const VOICEVOX_URL = process.env.VOICEVOX_URL || 'http://voicevox:50021';
|
||
const ttsCache = new Map(); // key: "speaker:text" → Buffer
|
||
const TTS_CACHE_MAX = 60; // 約60文を最大キャッシュ(メモリ節約)
|
||
let ttsBusy = false; // VOICEVOX は同時1リクエストのみ対応(排他ロック)
|
||
let preWarmBusy = false; // プリウォームが合成中(ユーザーリクエストを優先するために分離)
|
||
let userWaiting = false; // ユーザーリクエスト待機中 → プリウォームをスキップ
|
||
|
||
// 合成ヘルパー(/tts と /tts/warmup から共用)
|
||
async function ttsSynthesize(text, speaker) {
|
||
const queryRes = await fetch(
|
||
`${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
|
||
{ method: 'POST' }
|
||
);
|
||
if (!queryRes.ok) throw new Error(`VOICEVOX audio_query failed: ${queryRes.status}`);
|
||
const query = await queryRes.json();
|
||
query.speedScale = 1.08;
|
||
query.intonationScale = 1.15;
|
||
query.prePhonemeLength = 0.05;
|
||
query.postPhonemeLength = 0.1;
|
||
const synthRes = await fetch(`${VOICEVOX_URL}/synthesis?speaker=${speaker}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(query)
|
||
});
|
||
if (!synthRes.ok) throw new Error(`VOICEVOX synthesis failed: ${synthRes.status}`);
|
||
return Buffer.from(await synthRes.arrayBuffer());
|
||
}
|
||
|
||
// POST /tts — テキストを音声(WAV)に変換して返す(購入済みユーザーのみ)
|
||
r.post('/tts', authMiddleware, 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 = genAI.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' });
|
||
const username = req.query.u;
|
||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||
const result = await pool.query('SELECT id, name, invite_code, 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 — メンバー一覧 ?u=username
|
||
r.get('/together/members/:groupId', async (req, res) => {
|
||
const username = req.query.u;
|
||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||
|
||
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 のみ有効です' });
|
||
}
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
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: 'グループが見つかりません' });
|
||
|
||
if (!(await togetherEnsureMember(pool, res, group_id, shared_by, jwtUserId))) return;
|
||
|
||
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 は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMemberForShare(pool, res, req.params.id, username, jwtUserId))) return;
|
||
|
||
// 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 — フィード(リアクション付き)
|
||
// ?u=username&limit=N&cursor=<ISO timestamp>
|
||
r.get('/together/feed/:groupId', async (req, res) => {
|
||
const username = req.query.u;
|
||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||
|
||
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
||
const cursor = req.query.cursor;
|
||
const params = [req.params.groupId];
|
||
let cursorClause = '';
|
||
if (cursor) {
|
||
params.push(cursor);
|
||
cursorClause = 'AND s.shared_at < $2';
|
||
}
|
||
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
|
||
FROM together_shares s
|
||
LEFT JOIN together_reactions r ON r.share_id = s.id
|
||
WHERE s.group_id = $1 ${cursorClause}
|
||
GROUP BY s.id
|
||
ORDER BY s.shared_at DESC
|
||
LIMIT ${limit + 1}
|
||
`, params);
|
||
const rows = result.rows;
|
||
const hasMore = rows.length > limit;
|
||
const items = hasMore ? rows.slice(0, limit) : rows;
|
||
const nextCursor = items.length > 0 ? items[items.length - 1].shared_at : null;
|
||
res.json({ items, next_cursor: nextCursor, has_more: hasMore });
|
||
} catch (e) {
|
||
console.error('[together/feed]', e.message);
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
// GET /together/article/:shareId — アーカイブ本文取得 ?u=username
|
||
r.get('/together/article/:shareId', async (req, res) => {
|
||
const username = req.query.u;
|
||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMemberForShare(pool, res, req.params.shareId, username, jwtUserId))) return;
|
||
|
||
const result = await pool.query(
|
||
'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1',
|
||
[req.params.shareId]
|
||
);
|
||
if (result.rows.length === 0) return res.status(404).json({ error: '見つかりません' });
|
||
res.json(result.rows[0]);
|
||
} catch (e) {
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
// POST /together/react — リアクション toggle(like / star / fire)
|
||
r.post('/together/react', async (req, res) => {
|
||
const { share_id, username, type = 'like' } = req.body || {};
|
||
if (!share_id || !username) return res.status(400).json({ error: 'share_id と username は必須です' });
|
||
if (!['like', 'star', 'fire', 'read'].includes(type)) return res.status(400).json({ error: 'type は like/star/fire/read のみ有効です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMemberForShare(pool, res, share_id, username, jwtUserId))) return;
|
||
|
||
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 — コメント一覧 ?u=username
|
||
r.get('/together/comments/:shareId', async (req, res) => {
|
||
const username = req.query.u;
|
||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMemberForShare(pool, res, req.params.shareId, username, jwtUserId))) return;
|
||
|
||
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 は必須です' });
|
||
}
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
try {
|
||
if (!(await togetherEnsureMemberForShare(pool, res, share_id, username, jwtUserId))) return;
|
||
|
||
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 — キーワード / タグ検索 ?u=username
|
||
r.get('/together/search/:groupId', async (req, res) => {
|
||
const { q = '', tag = '', u: username = '' } = req.query;
|
||
if (!username) return res.status(400).json({ error: 'u (username) は必須です' });
|
||
const jwtUserId = getTogetherJwtUserId(req);
|
||
if (!(await togetherEnsureMember(pool, res, req.params.groupId, username, jwtUserId))) return;
|
||
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 RSS(XMLをregexでパース) ──────────────────────────────────
|
||
async function fetchConnpassRss() {
|
||
const url = 'https://connpass.com/explore/ja.atom';
|
||
const res = await fetch(url, {
|
||
headers: { 'Accept': 'application/atom+xml', 'User-Agent': 'Posimai/1.0' },
|
||
signal: AbortSignal.timeout(8000),
|
||
});
|
||
if (!res.ok) throw new Error(`connpass RSS ${res.status}`);
|
||
const xml = await res.text();
|
||
|
||
const entries = [...xml.matchAll(/<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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, "'").trim();
|
||
}
|
||
function evStripHtml(html) { return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); }
|
||
function evToDateStr(dt) { return dt.toISOString().slice(0, 10); }
|
||
function evToTimeStr(dt) {
|
||
const jst = new Date(dt.getTime() + 9 * 3600 * 1000);
|
||
return jst.toISOString().slice(11, 16);
|
||
}
|
||
function evGuessInterestTags(text) {
|
||
const tags = [];
|
||
if (/React|Vue|TypeScript|フロントエンド|Next\.js|Svelte/i.test(text)) tags.push('frontend');
|
||
if (/Go|Rust|Ruby|Python|PHP|バックエンド|API/i.test(text)) tags.push('backend');
|
||
if (/デザイン|UX|Figma|UI/i.test(text)) tags.push('design');
|
||
if (/AI|機械学習|LLM|GPT|Claude|Gemini/i.test(text)) tags.push('ai');
|
||
if (/インフラ|AWS|GCP|Azure|クラウド|Docker|Kubernetes/i.test(text)) tags.push('infra');
|
||
if (/iOS|Android|Flutter|React Native|モバイル/i.test(text)) tags.push('mobile');
|
||
if (/データ|分析|ML|データサイエンス/i.test(text)) tags.push('data');
|
||
if (/PM|プロダクト|プロダクトマネジメント/i.test(text)) tags.push('pm');
|
||
if (/初心者|入門|ビギナー/i.test(text)) tags.push('beginner');
|
||
return tags;
|
||
}
|
||
function evGuessAudienceTags(text) {
|
||
const tags = [];
|
||
if (/交流|ミートアップ|meetup/i.test(text)) tags.push('meetup');
|
||
if (/もくもく/i.test(text)) tags.push('mokumoku');
|
||
if (/セミナー|勉強会|study/i.test(text)) tags.push('seminar');
|
||
if (/ハンズオン|hands.?on/i.test(text)) tags.push('handson');
|
||
return tags;
|
||
}
|
||
|
||
// ── Uploads ───────────────────────────────
|
||
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||
app.use('/brain/api/uploads', express.static(UPLOADS_DIR));
|
||
app.use('/api/uploads', express.static(UPLOADS_DIR));
|
||
|
||
// ── Stripe Webhook (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);
|
||
});
|