2026-03-17 08:19:20 +00:00
// ============================================
// 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' ) ;
2026-03-25 23:31:11 +00:00
const jwt = require ( 'jsonwebtoken' ) ;
2026-04-02 10:18:14 +00:00
const os = require ( 'os' ) ;
const { execSync } = require ( 'child_process' ) ;
2026-04-05 03:29:48 +00:00
let RssParser = null ;
try { RssParser = require ( 'rss-parser' ) ; } catch ( _ ) { console . warn ( '[Feed] rss-parser not found, background fetch disabled' ) ; }
2026-03-25 23:31:11 +00:00
// ── 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 ) ;
2026-04-04 14:04:20 +00:00
// ── 汎用インメモリレートリミッター ──────────────────────────────
// 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 ) ;
2026-04-02 23:15:45 +00:00
// ── ユーティリティ ───────────────────────────────────────────────────
function escapeHtml ( str ) {
return String ( str )
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' ) ;
}
2026-03-25 23:31:11 +00:00
// ── Auth: JWT config ────────────────────────────────────────────────
2026-04-02 23:15:45 +00:00
if ( ! process . env . JWT _SECRET ) {
2026-04-09 22:55:08 +00:00
console . error ( '[SECURITY] JWT_SECRET env var is not set. Refusing to start.' ) ;
process . exit ( 1 ) ;
2026-04-02 23:15:45 +00:00
}
2026-04-09 22:55:08 +00:00
const JWT _SECRET = process . env . JWT _SECRET ;
2026-03-25 23:31:11 +00:00
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 ]
) ;
2026-04-05 06:03:04 +00:00
// 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 } ) ;
2026-03-25 23:31:11 +00:00
}
2026-03-17 08:19:20 +00:00
const app = express ( ) ;
2026-04-05 05:54:39 +00:00
// 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 ) ;
} ) ;
2026-03-17 08:19:20 +00:00
// ── CORS ──────────────────────────────────
// ALLOWED_ORIGINS 環境変数(カンマ区切り)+ 開発用ローカル
// posimai-*.vercel.app は新アプリ追加のたびに変更不要(ワイルドカード許可)
const extraOrigins = ( process . env . ALLOWED _ORIGINS || '' )
. split ( ',' ) . map ( o => o . trim ( ) ) . filter ( Boolean ) ;
function isAllowedOrigin ( origin ) {
2026-04-04 18:01:06 +00:00
if ( ! origin ) return false ; // origin なしは拒否( CSRF 対策)
2026-03-17 08:19:20 +00:00
if ( process . env . NODE _ENV !== 'production' && /^http:\/\/localhost(:\d+)?$/ . test ( origin ) ) return true ; // localhost 開発のみ
2026-04-04 18:01:06 +00:00
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
2026-03-17 08:19:20 +00:00
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 ( ) ;
} ) ;
2026-04-05 05:02:55 +00:00
// /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 ( ) ;
} ) ;
2026-03-17 08:19:20 +00:00
app . use ( cors ( {
origin : ( origin , cb ) => {
2026-04-05 05:02:55 +00:00
if ( ! origin ) {
// origin なし = サーバー間リクエスト( curl / Node fetch 等)。/health のみ通過させる
// それ以外のエンドポイントはCSRF対策で拒否
return cb ( null , false ) ;
}
2026-03-17 08:19:20 +00:00
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' ,
2026-04-09 14:45:55 +00:00
max : 15 ,
idleTimeoutMillis : 30000 ,
connectionTimeoutMillis : 5000 ,
} ) ;
// プールレベルの接続エラーをキャッチ(未処理のままにしない)
pool . on ( 'error' , ( err ) => {
console . error ( '[DB] Unexpected pool error:' , err . message ) ;
2026-03-17 08:19:20 +00:00
} ) ;
// ── 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 ) {
2026-03-25 23:31:11 +00:00
let token = '' ;
2026-03-17 08:19:20 +00:00
// 1. ヘッダーからの取得
const auth = req . headers . authorization || '' ;
if ( auth . toLowerCase ( ) . startsWith ( 'bearer ' ) ) {
2026-03-25 23:31:11 +00:00
token = auth . substring ( 7 ) . trim ( ) ;
2026-03-17 08:19:20 +00:00
}
// 2. クエリパラメータからの取得 (Bookmarklet等)
else if ( req . query . key ) {
2026-03-25 23:31:11 +00:00
token = req . query . key . trim ( ) ;
2026-03-17 08:19:20 +00:00
}
2026-03-25 23:31:11 +00:00
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 : '認証エラー: セッションが無効または期限切れです' } ) ;
}
}
2026-04-04 17:22:18 +00:00
// API key (internal users — skip purchase check)
2026-03-25 23:31:11 +00:00
const userId = KEY _MAP [ token ] ;
2026-03-17 08:19:20 +00:00
if ( ! userId ) return res . status ( 401 ) . json ( { error : '認証エラー: APIキーが無効です' } ) ;
req . userId = userId ;
2026-03-25 23:31:11 +00:00
req . authType = 'apikey' ;
2026-03-17 08:19:20 +00:00
next ( ) ;
}
2026-04-04 17:22:18 +00:00
// 購入済みチェックミドルウェア( 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 : '購入が必要です' ,
2026-04-11 05:36:33 +00:00
store _url : 'https://store.posimai.soar-enrich.com/index-c.html'
2026-04-04 17:22:18 +00:00
} ) ;
} catch ( e ) {
console . error ( '[Purchase] DB error:' , e . message ) ;
return res . status ( 500 ) . json ( { error : 'サーバーエラーが発生しました' } ) ;
}
}
2026-03-17 08:19:20 +00:00
// ── ソース抽出 ────────────────────────────
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' ; }
}
2026-04-02 10:18:14 +00:00
// ── 文字コード正規化 ─────────────────────────
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' ;
}
2026-04-09 14:45:55 +00:00
// ── 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 ;
}
2026-03-17 08:19:20 +00:00
// ── OGP フェッチ ───────────────────────────
2026-04-09 14:45:55 +00:00
const FETCH _META _MAX _BYTES = 2 * 1024 * 1024 ; // 2 MB 上限
2026-03-17 08:19:20 +00:00
async function fetchMeta ( url ) {
2026-04-09 14:45:55 +00:00
if ( ! isSsrfSafe ( url ) ) {
return { title : url . slice ( 0 , 300 ) , desc : '' , ogImage : '' , favicon : '' } ;
}
2026-03-17 08:19:20 +00:00
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 } ` ) ;
2026-04-09 14:45:55 +00:00
// レスポンスサイズを 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 ;
2026-03-17 08:19:20 +00:00
2026-04-02 10:18:14 +00:00
// 文字コード判定: 1) Content-Typeヘッダー優先 2) HTMLメタタグ確認
// iso-8859-1はバイト値0-255をロスレスでデコードするためcharset検出に最適
2026-03-17 08:19:20 +00:00
let encoding = 'utf-8' ;
2026-04-02 10:18:14 +00:00
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 ] ) ;
2026-03-17 08:19:20 +00:00
}
2026-04-02 10:18:14 +00:00
let html ;
try {
html = new TextDecoder ( encoding ) . decode ( buffer ) ;
} catch {
// TextDecoder が対象エンコーディング非対応の場合は UTF-8 フォールバック
html = new TextDecoder ( 'utf-8' , { fatal : false } ) . decode ( buffer ) ;
}
2026-03-17 08:19:20 +00:00
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 ) {
2026-04-09 14:45:55 +00:00
if ( ! isSsrfSafe ( url ) ) return null ;
2026-03-17 08:19:20 +00:00
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 ;
}
2026-04-09 14:49:25 +00:00
// レスポンスサイズを 1MB に制限( AI 分析に必要な本文量の上限)
const jinaContentLength = parseInt ( jinaResponse . headers . get ( 'content-length' ) || '0' , 10 ) ;
if ( jinaContentLength > 1024 * 1024 ) return null ;
2026-03-17 08:19:20 +00:00
let markdown = await jinaResponse . text ( ) ;
2026-04-09 14:49:25 +00:00
if ( markdown . length > 1024 * 1024 ) markdown = markdown . slice ( 0 , 1024 * 1024 ) ;
2026-03-17 08:19:20 +00:00
// 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 ;
}
2026-04-14 14:39:46 +00:00
async function analyzeWithGemini ( title , fullText , url , _retry = false ) {
if ( ! genAI ) return null ;
2026-03-17 08:19:20 +00:00
try {
const model = genAI . getGenerativeModel ( {
2026-04-14 14:12:50 +00:00
model : 'gemini-2.5-flash' ,
2026-03-17 08:19:20 +00:00
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 ( ) ) ;
}
2026-04-14 14:39:46 +00:00
// 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 ;
2026-03-17 08:19:20 +00:00
}
}
// ── 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) ` ,
2026-03-25 23:31:11 +00:00
// ── 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
) ` ,
2026-04-10 15:16:57 +00:00
` 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 ( )
) ` ,
2026-03-17 08:19:20 +00:00
] ;
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) ` ,
2026-03-20 14:01:18 +00:00
// together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理
2026-03-25 23:31:11 +00:00
// ── 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 ` ,
2026-04-04 17:22:18 +00:00
` ALTER TABLE users ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMPTZ ` ,
` ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_session_id TEXT ` ,
2026-04-05 05:01:41 +00:00
` 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 ` ,
2026-03-17 08:19:20 +00:00
] ;
for ( const sql of migrations ) {
await pool . query ( sql ) . catch ( e => console . warn ( '[DB] Migration warning:' , e . message ) ) ;
}
// 4. 初期ユーザー (API_KEYSから)
for ( const [ , userId ] of Object . entries ( KEY _MAP ) ) {
await pool . query (
` INSERT INTO users (user_id, name) VALUES ( $ 1, $ 2) ON CONFLICT DO NOTHING ` ,
[ userId , userId ]
) ;
}
console . log ( '[DB] Schema ready. Users:' , Object . values ( KEY _MAP ) . join ( ', ' ) ) ;
}
// ── ルーター ──────────────────────────────
function buildRouter ( ) {
const r = express . Router ( ) ;
2026-04-02 10:18:14 +00:00
// ヘルスチェック( Station コックピット向けに拡張)
2026-04-04 14:04:20 +00:00
// 認証なし: 最小限レスポンス(外部監視ツール向け)
// 認証あり( API Key / JWT) : 詳細システム情報を追加
2026-03-17 08:19:20 +00:00
r . get ( '/health' , ( req , res ) => {
2026-04-02 10:18:14 +00:00
res . setHeader ( 'Access-Control-Allow-Origin' , '*' ) ;
2026-04-04 14:04:20 +00:00
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 ) ;
2026-04-02 10:18:14 +00:00
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 ( _ ) { }
2026-04-11 23:13:58 +00:00
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 ( _ ) { }
2026-04-02 10:18:14 +00:00
res . json ( {
2026-04-04 14:04:20 +00:00
... base ,
2026-04-02 10:18:14 +00:00
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 ,
2026-04-11 23:13:58 +00:00
users ,
node _version : process . version ,
2026-04-02 10:18:14 +00:00
} ) ;
2026-03-17 08:19:20 +00:00
} ) ;
// 認証テスト (UI用)
r . get ( '/auth-test' , authMiddleware , ( req , res ) => {
res . json ( { ok : true , userId : req . userId } ) ;
} ) ;
2026-03-25 23:31:11 +00:00
// ── Auth: Magic Link ─────────────────────────────────────────────
// POST /api/auth/magic-link/send
r . post ( '/auth/magic-link/send' , async ( req , res ) => {
2026-04-10 15:05:18 +00:00
const { email , redirect } = req . body ;
2026-03-25 23:31:11 +00:00
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 ) {
2026-04-10 15:05:18 +00:00
const redirectSuffix = redirect ? ` &redirect= ${ encodeURIComponent ( redirect ) } ` : '' ;
const magicLinkUrl = ` ${ MAGIC _LINK _BASE _URL } /auth/verify?token= ${ token } ${ redirectSuffix } ` ;
2026-03-25 23:31:11 +00:00
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 : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
2026-04-05 06:03:04 +00:00
// GET /api/auth/session/verify — check current JWT + plan
2026-04-04 17:22:18 +00:00
r . get ( '/auth/session/verify' , authMiddleware , async ( req , res ) => {
if ( req . authType === 'apikey' ) {
2026-04-05 06:03:04 +00:00
return res . json ( { ok : true , userId : req . userId , authType : req . authType , plan : 'premium' , purchased : true } ) ;
2026-04-04 17:22:18 +00:00
}
try {
const result = await pool . query (
2026-04-05 06:03:04 +00:00
` SELECT plan, purchased_at FROM users WHERE user_id = $ 1 ` , [ req . userId ]
2026-04-04 17:22:18 +00:00
) ;
2026-04-05 06:03:04 +00:00
const plan = result . rows [ 0 ] ? . plan || 'free' ;
const purchased = plan === 'premium' ;
res . json ( { ok : true , userId : req . userId , authType : req . authType , plan , purchased } ) ;
2026-04-04 17:22:18 +00:00
} catch ( e ) {
2026-04-05 06:03:04 +00:00
res . json ( { ok : true , userId : req . userId , authType : req . authType , plan : 'free' , purchased : false } ) ;
2026-04-04 17:22:18 +00:00
}
2026-03-25 23:31:11 +00:00
} ) ;
2026-04-04 08:25:26 +00:00
// ── 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 ) => {
2026-04-04 14:04:20 +00:00
const state = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
webauthnChallenges . set ( ` oauth: ${ state } ` , { expiresAt : Date . now ( ) + 10 * 60 * 1000 } ) ;
2026-04-04 08:25:26 +00:00
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' ,
2026-04-04 14:04:20 +00:00
state ,
2026-04-04 08:25:26 +00:00
} ) ;
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 ) => {
2026-04-04 14:04:20 +00:00
const { code , state } = req . query ;
2026-04-04 08:25:26 +00:00
if ( ! code ) return res . redirect ( ` ${ OAUTH _BASE _URL } /login?error=no_code ` ) ;
2026-04-04 14:04:20 +00:00
if ( ! state || ! webauthnChallenges . has ( ` oauth: ${ state } ` ) ) {
return res . redirect ( ` ${ OAUTH _BASE _URL } /login?error=invalid_state ` ) ;
}
webauthnChallenges . delete ( ` oauth: ${ state } ` ) ;
2026-04-04 08:25:26 +00:00
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 (
2026-04-04 14:04:20 +00:00
` SELECT user_id FROM users WHERE email = $ 1 ` , [ email ]
2026-04-04 08:25:26 +00:00
) ;
let userId ;
if ( existing . rows . length > 0 ) {
2026-04-04 14:04:20 +00:00
userId = existing . rows [ 0 ] . user _id ;
await pool . query (
` UPDATE users SET email_verified = true WHERE user_id = $ 1 ` , [ userId ]
) ;
2026-04-04 08:25:26 +00:00
} else {
2026-04-04 14:04:20 +00:00
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 ]
2026-04-04 08:25:26 +00:00
) ;
}
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 ) => {
2026-04-04 14:04:20 +00:00
const state = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
webauthnChallenges . set ( ` oauth: ${ state } ` , { expiresAt : Date . now ( ) + 10 * 60 * 1000 } ) ;
2026-04-04 08:25:26 +00:00
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' ,
2026-04-04 14:04:20 +00:00
state ,
2026-04-04 08:25:26 +00:00
} ) ;
res . redirect ( ` https://github.com/login/oauth/authorize? ${ params } ` ) ;
} ) ;
// GET /api/auth/oauth/github/callback
r . get ( '/auth/oauth/github/callback' , async ( req , res ) => {
2026-04-04 14:04:20 +00:00
const { code , state } = req . query ;
2026-04-04 08:25:26 +00:00
if ( ! code ) return res . redirect ( ` ${ OAUTH _BASE _URL } /login?error=no_code ` ) ;
2026-04-04 14:04:20 +00:00
if ( ! state || ! webauthnChallenges . has ( ` oauth: ${ state } ` ) ) {
return res . redirect ( ` ${ OAUTH _BASE _URL } /login?error=invalid_state ` ) ;
}
webauthnChallenges . delete ( ` oauth: ${ state } ` ) ;
2026-04-04 08:25:26 +00:00
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 (
2026-04-04 14:04:20 +00:00
` SELECT user_id FROM users WHERE email = $ 1 ` , [ email ]
2026-04-04 08:25:26 +00:00
) ;
let userId ;
if ( existing . rows . length > 0 ) {
2026-04-04 14:04:20 +00:00
userId = existing . rows [ 0 ] . user _id ;
await pool . query (
` UPDATE users SET email_verified = true WHERE user_id = $ 1 ` , [ userId ]
) ;
2026-04-04 08:25:26 +00:00
} else {
2026-04-04 14:04:20 +00:00
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 ]
2026-04-04 08:25:26 +00:00
) ;
}
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 ` ) ;
}
} ) ;
2026-03-25 23:31:11 +00:00
// 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 ;
2026-04-04 14:04:20 +00:00
// レート制限: 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 : '試行回数が多すぎます。しばらくしてからお試しください' } ) ;
}
2026-03-25 23:31:11 +00:00
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 : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
2026-03-17 08:19:20 +00:00
// 記事一覧取得
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 {
2026-04-15 00:11:27 +00:00
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 ]
)
] ) ;
2026-03-17 08:19:20 +00:00
2026-04-15 00:11:27 +00:00
const countMap = Object . fromEntries ( countRows . map ( r => [ r . status , r . cnt ] ) ) ;
2026-03-17 08:19:20 +00:00
const counts = {
2026-04-15 00:11:27 +00:00
all : countRows . reduce ( ( s , r ) => s + r . cnt , 0 ) ,
unread : countMap [ 'inbox' ] || 0 ,
favorite : countMap [ 'favorite' ] || 0 ,
shared : countMap [ 'shared' ] || 0 ,
2026-03-17 08:19:20 +00:00
} ;
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' } ) ;
}
} ) ;
2026-04-09 11:48:17 +00:00
// ========== 記事保存(即時保存 + バックグラウンドメタ取得)==========
2026-03-17 08:19:20 +00:00
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' } ) ;
2026-04-09 11:48:17 +00:00
const source = clientSource || extractSource ( url ) ;
const domain = parsedUrl . hostname ;
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
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 ` , '' ] ) ;
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
const article = articleQuery . rows [ 0 ] ;
res . json ( { ok : true , article , aiStatus : 'pending' } ) ;
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
// 2. バックグラウンドでメタ情報取得 → DB更新 → AI分析
const savedUserId = req . userId ;
setImmediate ( async ( ) => {
try {
const meta = await fetchMeta ( url ) ;
let fullText = content || null ;
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
if ( ! fullText || fullText . trim ( ) . length === 0 ) {
const jinaText = await fetchFullTextViaJina ( url ) ;
fullText = jinaText || meta . desc || '' ;
}
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
const finalTitle = clientTitle || meta . title ;
2026-04-04 14:04:20 +00:00
await pool . query ( `
2026-04-09 11:48:17 +00:00
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 ) => {
2026-04-15 00:11:27 +00:00
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 ;
}
2026-04-09 11:48:17 +00:00
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 ) {
2026-04-15 00:11:27 +00:00
console . error ( '[Background Meta Error]:' , e . message || e ) ;
2026-04-09 11:48:17 +00:00
}
} ) ;
2026-03-17 08:19:20 +00:00
} catch ( e ) {
if ( e . code === '23505' ) return res . status ( 409 ) . json ( { error : 'すでに保存済みです' } ) ;
2026-04-09 11:48:17 +00:00
console . error ( e ) ;
if ( ! res . headersSent ) res . status ( 500 ) . json ( { error : 'DB error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// ステータス更新
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' } ) ; }
} ) ;
2026-04-09 11:48:17 +00:00
// クイック保存 (Bookmarklet等からのGET) — 即時保存 + バックグラウンドメタ取得
2026-03-17 08:19:20 +00:00
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>' ) ;
2026-04-09 11:48:17 +00:00
let parsedUrl ;
try { parsedUrl = new URL ( url ) ; } catch { return res . status ( 400 ) . send ( '<h1>Invalid URL</h1>' ) ; }
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
const domain = parsedUrl . hostname ;
const source = extractSource ( url ) ;
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
try {
// 1. URLだけ即座に保存
2026-03-17 08:19:20 +00:00
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
2026-04-09 11:48:17 +00:00
SET source = EXCLUDED . source , summary = '⏳ 再分析中...'
` , [req.userId, url, domain, null, '⏳ AI分析中...', ['その他'], source, 3,
` https://www.google.com/s2/favicons?domain= ${ domain } &sz=32 ` , '' ] ) ;
2026-03-17 08:19:20 +00:00
2026-04-09 11:48:17 +00:00
// 2. HTMLレスポンスを即座に返す
2026-03-17 08:19:20 +00:00
res . send ( `
< ! DOCTYPE html >
< html > < head > < meta charset = "utf-8" > < title > 保存完了 < / t i t l e > < / h e a d >
< body style = "font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2" >
2026-04-09 11:48:17 +00:00
< h1 style = "color:#6EE7B7" > ✓ 保存しました < / h 1 >
< p style = "color:#888" > $ { escapeHtml ( domain ) } < / p >
< p style = "color:#888" > タイトル ・ AI分析をバックグラウンドで取得中 ... < / p >
< script > setTimeout ( ( ) => window . close ( ) , 1200 ) < / s c r i p t >
2026-03-17 08:19:20 +00:00
< / b o d y > < / h t m l >
` );
2026-04-09 11:48:17 +00:00
// 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 ) => {
2026-04-15 00:11:27 +00:00
if ( ! ai ) {
await pool . query (
` UPDATE articles SET summary=NULL WHERE user_id= $ 1 AND url= $ 2 AND summary LIKE '⏳%' ` ,
[ savedUserId , url ]
) ;
return ;
}
2026-04-09 11:48:17 +00:00
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 ) ;
}
} ) ;
2026-03-17 08:19:20 +00:00
} catch ( e ) {
2026-04-15 00:11:27 +00:00
if ( ! res . headersSent ) res . status ( 500 ) . send ( ` <h1>保存に失敗しました</h1> ` ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// ========== 履歴機能 ==========
// 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 ) => {
2026-03-20 14:01:18 +00:00
const limit = Math . min ( parseInt ( req . query . limit || '50' ) || 50 , 100 ) ;
2026-03-17 08:19:20 +00:00
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 {
2026-03-20 14:01:18 +00:00
const limit = Math . min ( parseInt ( req . query . limit || '50' ) || 50 , 100 ) ;
2026-03-17 08:19:20 +00:00
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' } ) ;
2026-04-04 14:04:20 +00:00
// レート制限: ユーザーごとに 10回/時間
if ( ! checkRateLimit ( 'gemini_suggest_tags' , req . userId , 10 , 60 * 60 * 1000 ) ) {
return res . status ( 429 ) . json ( { error : 'AI提案の利用回数が上限に達しました。1時間後に再試行してください' } ) ;
}
2026-03-17 08:19:20 +00:00
const { title = '' , body = '' } = req . body || { } ;
if ( ! title && ! body ) return res . status ( 400 ) . json ( { error : 'title or body required' } ) ;
try {
const model = genAI . getGenerativeModel ( {
2026-04-14 14:12:50 +00:00
model : 'gemini-2.5-flash' ,
2026-03-17 08:19:20 +00:00
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 ) ;
2026-03-26 14:06:06 +00:00
const url = ` https://api.soar-enrich.com/brain/api/uploads/ ${ name } ` ;
2026-03-17 08:19:20 +00:00
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 ) => {
2026-03-20 14:01:18 +00:00
const days = Math . min ( parseInt ( req . query . days || '90' ) || 90 , 365 ) ;
2026-03-17 08:19:20 +00:00
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
2026-03-20 14:01:18 +00:00
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 ( )
2026-03-17 08:19:20 +00:00
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 ) => {
2026-03-20 14:01:18 +00:00
const days = Math . min ( parseInt ( req . query . days || '30' ) || 30 , 365 ) ;
2026-03-17 08:19:20 +00:00
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 ) => {
2026-03-20 14:01:18 +00:00
const limit = Math . min ( parseInt ( req . query . limit || '20' ) || 20 , 100 ) ;
2026-03-17 08:19:20 +00:00
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' } ) ; }
} ) ;
2026-03-22 08:06:14 +00:00
// ── 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 {
2026-04-05 03:29:48 +00:00
// default_user のメディア(共通)+ ユーザー自身のメディアを統合して返す
2026-03-22 08:06:14 +00:00
const result = await pool . query (
2026-04-05 03:29:48 +00:00
` 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 ` ,
2026-03-22 08:06:14 +00:00
[ 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 ) => {
2026-04-05 03:29:48 +00:00
const { name , feed _url , site _url = '' , is _active = true } = req . body || { } ;
2026-03-22 08:06:14 +00:00
if ( ! name || ! feed _url ) return res . status ( 400 ) . json ( { error : 'name and feed_url required' } ) ;
2026-04-05 03:29:48 +00:00
// カテゴリ自動判定(指定があればそのまま使用)
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' ;
}
2026-03-22 08:06:14 +00:00
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 ] ) ;
2026-04-05 03:29:48 +00:00
} catch ( e ) {
if ( e . code === '23505' ) return res . status ( 409 ) . json ( { error : 'このメディアはすでに追加済みです' } ) ;
console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ;
}
2026-03-22 08:06:14 +00:00
} ) ;
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' } ) ; }
} ) ;
2026-04-05 03:29:48 +00:00
// ── 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 } ) ;
2026-04-06 00:09:26 +00:00
} catch ( e ) { console . error ( e ) ; res . status ( 500 ) . json ( { error : 'Internal server error' } ) ; }
2026-04-05 03:29:48 +00:00
} ) ;
2026-03-17 08:19:20 +00:00
// ── 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リクエストのみ対応( 排他ロック)
2026-03-22 09:23:29 +00:00
let preWarmBusy = false ; // プリウォームが合成中(ユーザーリクエストを優先するために分離)
2026-03-25 14:23:02 +00:00
let userWaiting = false ; // ユーザーリクエスト待機中 → プリウォームをスキップ
2026-03-17 08:19:20 +00:00
// 合成ヘルパー(/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 ( ) ) ;
}
2026-04-11 06:05:23 +00:00
// POST /tts — テキストを音声( WAV) に変換して返す( 購入済みユーザーのみ)
r . post ( '/tts' , authMiddleware , purchaseMiddleware , async ( req , res ) => {
2026-03-17 08:19:20 +00:00
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 ) ;
}
2026-03-25 14:23:02 +00:00
// ユーザー待機フラグを立てる → プリウォームが残り記事をスキップして ttsBusy を解放
userWaiting = true ;
const deadline = Date . now ( ) + 30000 ;
while ( ttsBusy && Date . now ( ) < deadline ) {
await new Promise ( r => setTimeout ( r , 200 ) ) ;
2026-03-22 09:23:29 +00:00
}
2026-03-25 14:23:02 +00:00
userWaiting = false ;
2026-03-17 08:19:20 +00:00
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 — 指定テキストがキャッシュ済みか確認(ポーリング用)
2026-04-11 06:05:23 +00:00
r . post ( '/tts/ready' , authMiddleware , purchaseMiddleware , ( req , res ) => {
2026-03-17 08:19:20 +00:00
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 をバックグラウンドで実行。
2026-04-11 06:05:23 +00:00
r . post ( '/tts/warmup' , authMiddleware , purchaseMiddleware , async ( req , res ) => {
2026-03-17 08:19:20 +00:00
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 ) ;
2026-03-25 14:23:02 +00:00
res . status ( 202 ) . json ( { queued : valid . length } ) ; // 即座に返す(合成は行わない)
2026-03-17 08:19:20 +00:00
} ) ;
// 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 } ) ;
}
} ) ;
2026-03-25 14:23:02 +00:00
// Brief クライアントと同じ前処理(キャッシュキーを一致させるため)
function preWarmPreprocess ( t ) {
return ( t || '' )
. replace ( /https?:\/\/\S+/g , '' )
. replace ( /[「」『』【】〔〕《》]/g , '' )
. replace ( /([。!?])([^\s])/g , '$1 $2' )
. replace ( /\s{2,}/g , ' ' )
. trim ( ) ;
}
2026-03-17 08:19:20 +00:00
// ── サーバー側自動プリウォーム ──────────────────────────────────
// 起動時 + 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 ;
2026-03-25 14:23:02 +00:00
// ブラウザと同じロジックでテキスト生成( Brief の speechQueue + preprocessText と完全一致させる)
2026-03-17 08:19:20 +00:00
const texts = [ ] ;
articles . forEach ( ( a , i ) => {
const prefix = i === 0 ? '最初のニュースです。' : '続いて。' ;
2026-03-25 14:23:02 +00:00
texts . push ( preWarmPreprocess ( ` ${ prefix } ${ a . source || '' } より。 ${ a . title || '' } ` ) ) ;
2026-03-17 08:19:20 +00:00
} ) ;
2026-03-25 14:23:02 +00:00
texts . push ( preWarmPreprocess ( '本日のブリーフィングは以上です。' ) ) ;
2026-03-17 08:19:20 +00:00
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 ;
}
2026-03-25 14:23:02 +00:00
if ( ttsBusy || userWaiting ) {
console . log ( ` [TTS pre-warm] skip (user waiting): ${ text . substring ( 0 , 25 ) } ` ) ;
2026-03-22 09:23:29 +00:00
continue ;
}
2026-03-25 14:23:02 +00:00
ttsBusy = true ;
2026-03-17 08:19:20 +00:00
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 {
2026-03-25 14:23:02 +00:00
ttsBusy = false ;
2026-03-17 08:19:20 +00:00
}
}
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 ) ;
2026-04-10 15:05:18 +00:00
res . status ( 500 ) . json ( { events : [ ] , error : 'イベント取得に失敗しました' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// ── 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 ) {
2026-04-09 14:49:25 +00:00
if ( ! url || ! isSsrfSafe ( url ) ) {
2026-03-17 08:19:20 +00:00
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' } ,
2026-03-17 15:07:40 +00:00
signal : AbortSignal . timeout ( 30000 ) ,
2026-03-17 08:19:20 +00:00
} ) ;
if ( ! jinaRes . ok ) throw new Error ( ` Jina ${ jinaRes . status } ` ) ;
2026-04-09 14:49:25 +00:00
let fullContent = await jinaRes . text ( ) ;
// レスポンスサイズを 1MB に制限( DB の full_content カラムおよびGemini入力量の上限)
if ( fullContent . length > 1024 * 1024 ) fullContent = fullContent . slice ( 0 , 1024 * 1024 ) ;
2026-03-17 08:19:20 +00:00
2026-03-17 15:07:40 +00:00
// Jina Reader のレスポンス先頭から "Title: ..." を抽出
const titleMatch = fullContent . match ( /^Title:\s*(.+)/m ) ;
const jinaTitle = titleMatch ? titleMatch [ 1 ] . trim ( ) . slice ( 0 , 300 ) : null ;
2026-03-17 08:19:20 +00:00
await pool . query (
2026-03-17 15:07:40 +00:00
` UPDATE together_shares SET full_content= $ 1, title=COALESCE(title, $ 2) WHERE id= $ 3 ` ,
[ fullContent , jinaTitle , shareId ]
2026-03-17 08:19:20 +00:00
) ;
let summary = null ;
2026-03-17 15:07:40 +00:00
let tags = [ ] ;
2026-03-17 08:19:20 +00:00
if ( genAI && fullContent ) {
// 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す
const bodyStart = fullContent . search ( /^#{1,2}\s/m ) ;
const excerpt = ( bodyStart >= 0 ? fullContent . slice ( bodyStart ) : fullContent ) . slice ( 0 , 4000 ) ;
2026-04-15 00:11:27 +00:00
const model = genAI . getGenerativeModel ( { model : 'gemini-2.5-flash' } ) ;
2026-03-22 08:06:14 +00:00
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 } ` ;
2026-03-17 15:07:40 +00:00
const timeoutP = new Promise ( ( _ , reject ) => setTimeout ( ( ) => reject ( new Error ( 'timeout' ) ) , 30000 ) ) ;
2026-03-17 08:19:20 +00:00
const result = await Promise . race ( [ model . generateContent ( prompt ) , timeoutP ] ) ;
2026-03-17 15:07:40 +00:00
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 ) ;
}
2026-03-17 08:19:20 +00:00
}
await pool . query (
2026-03-17 15:07:40 +00:00
` UPDATE together_shares SET summary= $ 1, tags= $ 2, archive_status='done' WHERE id= $ 3 ` ,
[ summary , tags , shareId ]
2026-03-17 08:19:20 +00:00
) ;
} 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 ) => {
2026-03-20 14:01:18 +00:00
if ( ! /^[a-zA-Z0-9_-]+$/ . test ( req . params . groupId ) ) return res . status ( 400 ) . json ( { error : 'invalid groupId' } ) ;
2026-03-17 08:19:20 +00:00
try {
2026-04-10 15:05:18 +00:00
const result = await pool . query ( 'SELECT id, name, created_at FROM together_groups WHERE id=$1' , [ req . params . groupId ] ) ;
2026-03-17 08:19:20 +00:00
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'グループが見つかりません' } ) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( e ) {
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// GET /together/members/:groupId — メンバー一覧
r . get ( '/together/members/:groupId' , async ( req , res ) => {
try {
const result = await pool . query (
'SELECT username, joined_at FROM together_members WHERE group_id=$1 ORDER BY joined_at' ,
[ req . params . groupId ]
) ;
res . json ( result . rows ) ;
} catch ( e ) {
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// 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 は必須です' } ) ;
2026-03-17 08:26:59 +00:00
if ( url ) {
2026-04-09 14:49:25 +00:00
if ( ! isSsrfSafe ( url ) ) return res . status ( 400 ) . json ( { error : 'url は http/https のみ有効です' } ) ;
2026-03-17 08:26:59 +00:00
}
2026-03-17 08:19:20 +00:00
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 : 'グループが見つかりません' } ) ;
2026-04-11 14:03:49 +00:00
// グループメンバーであることを確認(非メンバーの投稿を防止)
const memberCheck = await pool . query (
'SELECT 1 FROM together_members WHERE group_id=$1 AND username=$2' ,
[ group _id , shared _by ]
) ;
if ( memberCheck . rows . length === 0 ) return res . status ( 403 ) . json ( { error : 'グループのメンバーではありません' } ) ;
2026-03-17 08:19:20 +00:00
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 ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
2026-03-17 15:07:40 +00:00
// DELETE /together/share/:id — 自分の投稿を削除
r . delete ( '/together/share/:id' , async ( req , res ) => {
const { username } = req . body || { } ;
if ( ! username ) return res . status ( 400 ) . json ( { error : 'username は必須です' } ) ;
try {
2026-04-11 14:03:49 +00:00
// shared_by が一致する行のみ削除(なければ 403)
2026-03-17 15:07:40 +00:00
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 ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 15:07:40 +00:00
}
} ) ;
2026-03-17 08:19:20 +00:00
// GET /together/feed/:groupId — フィード(リアクション・コメント数付き)
r . get ( '/together/feed/:groupId' , async ( req , res ) => {
try {
const result = await pool . query ( `
SELECT
s . * ,
COALESCE (
json _agg ( DISTINCT jsonb _build _object ( 'username' , r . username , 'type' , r . type ) )
FILTER ( WHERE r . username IS NOT NULL ) , '[]'
) AS reactions ,
COUNT ( DISTINCT c . id ) : : int AS comment _count
FROM together _shares s
LEFT JOIN together _reactions r ON r . share _id = s . id
LEFT JOIN together _comments c ON c . share _id = s . id
WHERE s . group _id = $1
GROUP BY s . id
ORDER BY s . shared _at DESC
LIMIT 50
` , [req.params.groupId]);
res . json ( result . rows ) ;
} catch ( e ) {
console . error ( '[together/feed]' , e . message ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// GET /together/article/:shareId — アーカイブ本文取得
r . get ( '/together/article/:shareId' , async ( req , res ) => {
try {
const result = await pool . query (
'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1' ,
[ req . params . shareId ]
) ;
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : '見つかりません' } ) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( e ) {
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// 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 は必須です' } ) ;
2026-03-17 08:26:59 +00:00
if ( ! [ 'like' , 'star' , 'fire' ] . includes ( type ) ) return res . status ( 400 ) . json ( { error : 'type は like/star/fire のみ有効です' } ) ;
2026-03-17 08:19:20 +00:00
try {
2026-04-11 14:03:49 +00:00
// share の group に対してメンバーであることを確認
const memberCheck = await pool . query (
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2' ,
[ share _id , username ]
) ;
if ( memberCheck . rows . length === 0 ) return res . status ( 403 ) . json ( { error : 'グループのメンバーではありません' } ) ;
2026-03-17 08:19:20 +00:00
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 ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// GET /together/comments/:shareId — コメント一覧
r . get ( '/together/comments/:shareId' , async ( req , res ) => {
try {
const result = await pool . query (
'SELECT * FROM together_comments WHERE share_id=$1 ORDER BY created_at' ,
[ req . params . shareId ]
) ;
res . json ( result . rows ) ;
} catch ( e ) {
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// POST /together/comments — コメント投稿
r . post ( '/together/comments' , async ( req , res ) => {
const { share _id , username , body } = req . body || { } ;
if ( ! share _id || ! username || ! body ? . trim ( ) ) {
return res . status ( 400 ) . json ( { error : 'share_id, username, body は必須です' } ) ;
}
try {
2026-04-11 14:03:49 +00:00
// share の group に対してメンバーであることを確認
const memberCheck = await pool . query (
'SELECT 1 FROM together_members m JOIN together_shares s ON s.group_id=m.group_id WHERE s.id=$1 AND m.username=$2' ,
[ share _id , username ]
) ;
if ( memberCheck . rows . length === 0 ) return res . status ( 403 ) . json ( { error : 'グループのメンバーではありません' } ) ;
2026-03-17 08:19:20 +00:00
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 ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
// GET /together/search/:groupId — キーワード / タグ検索
r . get ( '/together/search/:groupId' , async ( req , res ) => {
const { q = '' , tag = '' } = req . query ;
if ( ! q && ! tag ) return res . status ( 400 ) . json ( { error : 'q または tag が必要です' } ) ;
try {
const keyword = q ? ` % ${ q } % ` : '' ;
const result = await pool . query ( `
SELECT id , shared _by , url , title , message , tags , summary , archive _status , shared _at
FROM together _shares
WHERE group _id = $1
AND (
( $2 != '' AND ( title ILIKE $2 OR message ILIKE $2 OR full _content ILIKE $2 ) )
OR ( $3 != '' AND $3 = ANY ( tags ) )
)
ORDER BY shared _at DESC
LIMIT 30
` , [req.params.groupId, keyword, tag]);
res . json ( result . rows ) ;
} catch ( e ) {
console . error ( '[together/search]' , e . message ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
2026-03-17 08:19:20 +00:00
}
} ) ;
2026-03-30 15:25:44 +00:00
// ── Atlas: GitHub scan proxy ───────────────────────────────────
r . get ( '/atlas/github-scan' , ( req , res ) => {
2026-04-10 15:05:18 +00:00
const token = ( req . headers . authorization || '' ) . replace ( /^Bearer\s+/i , '' ) . trim ( ) ;
2026-03-30 15:25:44 +00:00
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 ) => {
2026-04-10 15:05:18 +00:00
const token = ( req . headers . authorization || '' ) . replace ( /^Bearer\s+/i , '' ) . trim ( ) ;
2026-03-30 15:25:44 +00:00
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' } ) ; } ) ;
2026-04-06 00:09:26 +00:00
req2 . on ( 'error' , ( e ) => { console . error ( '[proxy] error:' , e . code , e . message ) ; res . status ( 500 ) . json ( { error : 'Proxy error' , code : e . code } ) ; } ) ;
2026-03-30 15:25:44 +00:00
req2 . end ( ) ;
} ) ;
// ── Atlas: Tailscale scan proxy ────────────────────────────────
r . get ( '/atlas/tailscale-scan' , ( req , res ) => {
2026-04-10 15:05:18 +00:00
const token = ( req . headers . authorization || '' ) . replace ( /^Bearer\s+/i , '' ) . trim ( ) ;
2026-03-30 15:25:44 +00:00
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 ) ;
2026-04-06 00:09:26 +00:00
res . status ( 500 ) . json ( { error : 'Scan error' , code : e . code } ) ;
2026-03-30 15:25:44 +00:00
} ) ;
req2 . end ( ) ;
} ) ;
2026-03-17 08:19:20 +00:00
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 ) ) ;
2026-04-10 15:16:57 +00:00
// ── Stripe Webhook (routes/stripe.js) ────────────────────────────────
2026-04-04 17:22:18 +00:00
// rawBody が必要なため express.json() より前・router より前に配置
2026-04-10 15:16:57 +00:00
const { handleWebhook : handleStripeWebhook } = require ( './routes/stripe' ) ( pool ) ;
2026-04-04 17:22:18 +00:00
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 )
) ;
2026-04-10 15:16:57 +00:00
// ── Ponshu Room ライセンス (routes/ponshu.js) ─────────────────────────
const ponshuRouter = require ( './routes/ponshu' ) ( pool , authMiddleware ) ;
app . use ( '/brain/api' , ponshuRouter ) ;
app . use ( '/api' , ponshuRouter ) ;
2026-03-17 08:19:20 +00:00
// ── マウント( Tailscale経由と直接アクセスの両方対応)
const router = buildRouter ( ) ;
app . use ( '/brain/api' , router ) ; // Tailscale Funnel 経由
app . use ( '/api' , router ) ; // ローカル直接アクセス
// ── 起動 ──────────────────────────────────
const PORT = parseInt ( process . env . PORT || '8090' ) ;
2026-04-05 03:29:48 +00:00
// ── 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 ;
}
2026-03-25 23:31:11 +00:00
loadWebauthn ( )
. then ( ( ) => initDB ( ) )
2026-03-17 08:19:20 +00:00
. then ( ( ) => {
app . listen ( PORT , '0.0.0.0' , ( ) => {
2026-03-17 09:10:55 +00:00
console . log ( ` \n Posimai Brain API ` ) ;
2026-03-17 08:19:20 +00:00
console . log ( ` Port: ${ PORT } ` ) ;
console . log ( ` Gemini: ${ genAI ? 'enabled' : 'disabled (no key)' } ` ) ;
2026-03-25 23:31:11 +00:00
console . log ( ` WebAuthn: rpID= ${ WEBAUTHN _RP _ID } ` ) ;
2026-03-17 08:19:20 +00:00
console . log ( ` Users: ${ Object . values ( KEY _MAP ) . join ( ', ' ) || '(none - set API_KEYS)' } ` ) ;
console . log ( ` Local: http://localhost: ${ PORT } /api/health ` ) ;
2026-03-26 14:06:06 +00:00
console . log ( ` Public: https://api.soar-enrich.com/brain/api/health ` ) ;
2026-03-17 08:19:20 +00:00
} ) ;
2026-04-05 03:29:48 +00:00
// 起動直後に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)' ) ;
}
2026-03-17 08:19:20 +00:00
} )
. catch ( err => {
2026-03-25 23:31:11 +00:00
console . error ( '[FATAL] Startup failed:' , err . message ) ;
2026-03-17 08:19:20 +00:00
process . exit ( 1 ) ;
} ) ;