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-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-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 ────────────────────────────────────────────────
const JWT _SECRET = process . env . JWT _SECRET || 'dev-secret-CHANGE-IN-PRODUCTION' ;
2026-04-02 23:15:45 +00:00
if ( ! process . env . JWT _SECRET ) {
console . error ( '[SECURITY] JWT_SECRET is not set. Using insecure default. Set JWT_SECRET env var in production!' ) ;
}
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 ]
) ;
return jwt . sign ( { userId , sid : sessionId } , JWT _SECRET , { expiresIn : JWT _TTL _SECONDS } ) ;
}
2026-03-17 08:19:20 +00:00
const app = express ( ) ;
app . use ( express . json ( { limit : '10mb' } ) ) ;
// ── CORS ──────────────────────────────────
// ALLOWED_ORIGINS 環境変数(カンマ区切り)+ 開発用ローカル
// posimai-*.vercel.app は新アプリ追加のたびに変更不要(ワイルドカード許可)
const extraOrigins = ( process . env . ALLOWED _ORIGINS || '' )
. split ( ',' ) . map ( o => o . trim ( ) ) . filter ( Boolean ) ;
function isAllowedOrigin ( origin ) {
if ( ! origin ) return true ; // 同一オリジン
if ( process . env . NODE _ENV !== 'production' && /^http:\/\/localhost(:\d+)?$/ . test ( origin ) ) return true ; // localhost 開発のみ
if ( /^https:\/\/posimai-[^.]+\.vercel\.app$/ . test ( origin ) ) return true ; // 全 Posimai アプリ
if ( extraOrigins . includes ( origin ) ) return true ; // 追加許可
return false ;
}
// Chrome の Private Network Access ポリシー対応( cors より前に置く必要がある)
// cors() が OPTIONS preflight を先に完結させるため、後に置いても実行されない
app . use ( ( req , res , next ) => {
if ( req . headers [ 'access-control-request-private-network' ] ) {
res . setHeader ( 'Access-Control-Allow-Private-Network' , 'true' ) ;
}
next ( ) ;
} ) ;
app . use ( cors ( {
origin : ( origin , cb ) => {
if ( isAllowedOrigin ( origin ) ) cb ( null , true ) ;
else cb ( new Error ( 'CORS not allowed' ) ) ;
} ,
methods : [ 'GET' , 'POST' , 'PATCH' , 'DELETE' , 'OPTIONS' ] ,
allowedHeaders : [ 'Content-Type' , 'Authorization' ]
} ) ) ;
// ── PostgreSQL ────────────────────────────
const pool = new Pool ( {
host : process . env . DB _HOST || 'db' ,
port : parseInt ( process . env . DB _PORT || '5432' ) ,
user : process . env . DB _USER || 'gitea' ,
password : process . env . DB _PASSWORD || '' ,
database : 'posimai_brain' ,
max : 5
} ) ;
// ── Gemini ────────────────────────────────
const genAI = process . env . GEMINI _API _KEY
? new GoogleGenerativeAI ( process . env . GEMINI _API _KEY ) : null ;
2026-03-17 15:07:40 +00:00
// Together 専用インスタンス(メインキーを共用)
const genAITogether = genAI ;
2026-03-17 08:19:20 +00:00
// ── 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 : '認証エラー: セッションが無効または期限切れです' } ) ;
}
}
// API key (legacy)
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 ( ) ;
}
// ── ソース抽出 ────────────────────────────
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-03-17 08:19:20 +00:00
// ── OGP フェッチ ───────────────────────────
async function fetchMeta ( url ) {
try {
const res = await fetch ( url , {
headers : { 'User-Agent' : 'Mozilla/5.0 (compatible; PosimaiBot/1.0)' } ,
signal : AbortSignal . timeout ( 6000 )
} ) ;
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` ) ;
const buffer = await res . arrayBuffer ( ) ;
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 ) {
try {
console . log ( ` [Brain API] Fetching full text via Jina Reader for: ${ url } ` ) ;
const jinaResponse = await fetch ( ` https://r.jina.ai/ ${ url } ` , {
headers : {
'User-Agent' : 'Mozilla/5.0 Posimai Brain Bot'
} ,
signal : AbortSignal . timeout ( 15000 )
} ) ;
if ( ! jinaResponse . ok ) {
console . warn ( ` [Brain API] Jina Reader returned status ${ jinaResponse . status } ` ) ;
return null ;
}
let markdown = await jinaResponse . text ( ) ;
// Markdown Content: マーカーの後ろを抽出
const contentMarker = 'Markdown Content:' ;
const contentIndex = markdown . indexOf ( contentMarker ) ;
if ( contentIndex !== - 1 ) {
markdown = markdown . substring ( contentIndex + contentMarker . length ) . trim ( ) ;
}
// 画像参照を除去( Readerと同じロジック)
markdown = markdown . replace ( /!\[Image\s+\d+[^\]]*\]\([^)]*\)/gmi , '' ) ;
markdown = markdown . replace ( /!\[Image\s+\d+[^\]]*\]/gmi , '' ) ;
markdown = markdown . replace ( /^\s*\*?\s*!\[?Image\s+\d+[^\n]*/gmi , '' ) ;
markdown = markdown . replace ( /\[\]\([^)]*\)/gm , '' ) ;
console . log ( ` [Brain API] ✓ Fetched ${ markdown . length } chars via Jina Reader ` ) ;
return markdown ;
} catch ( error ) {
console . error ( '[Brain API] Jina Reader fetch failed:' , error . message ) ;
return null ;
}
}
// ── Gemini 分析 ───────────────────────────
const TOPIC _CANDIDATES = [
'AI' , 'React' , 'Next.js' , 'TypeScript' , 'Node.js' ,
'Flutter' , 'Docker' , 'PostgreSQL' ,
'日本酒' , 'ガジェット' , 'キャリア' , 'その他'
] ;
function smartExtract ( text , maxLen ) {
if ( ! text || text . length <= maxLen ) return text || '' ;
const halfLen = Math . floor ( maxLen / 2 ) ;
const front = text . substring ( 0 , halfLen ) ;
const back = text . substring ( text . length - halfLen ) ;
return front + '\n\n[...中略...]\n\n' + back ;
}
async function analyzeWithGemini ( title , fullText , url ) {
if ( ! genAI ) return { summary : ( fullText || '' ) . slice ( 0 , 120 ) || '(要約なし)' , topics : [ 'その他' ] , readingTime : 3 } ;
try {
const model = genAI . getGenerativeModel ( {
model : 'gemini-2.0-flash-lite' ,
generationConfig : { responseMimeType : 'application/json' }
} ) ;
const prompt = ` 記事分析してJSONで返答:
タイトル : $ { title . slice ( 0 , 100 ) }
本文 :
$ { smartExtract ( fullText || '' , 5000 ) }
{ "summary" : "3文要約" , "topics" : [ "最大2個" ] , "readingTime" : 3 }
候補 : $ { TOPIC _CANDIDATES . slice ( 0 , 15 ) . join ( ',' ) } ` ;
const timeoutPromise = new Promise ( ( _ , reject ) =>
setTimeout ( ( ) => reject ( new Error ( 'Gemini timeout' ) ) , 15000 )
) ;
let result ;
result = await Promise . race ( [
model . generateContent ( prompt ) ,
timeoutPromise
] ) ;
let raw = result . response . text ( ) . trim ( ) ;
// ```json や ``` で囲まれている場合の除去を堅牢化
raw = raw . replace ( /^```(json)?/i , '' ) . replace ( /```$/i , '' ) . trim ( ) ;
const parsed = JSON . parse ( raw ) ;
const validTopics = ( parsed . topics || [ ] )
. filter ( t => TOPIC _CANDIDATES . includes ( t ) ) . slice ( 0 , 2 ) ;
if ( validTopics . length === 0 ) validTopics . push ( 'その他' ) ;
return {
summary : String ( parsed . summary || '' ) . slice ( 0 , 300 ) ,
topics : validTopics ,
readingTime : Math . max ( 1 , parseInt ( parsed . readingTime ) || 3 )
} ;
} catch ( e ) {
console . error ( '[Gemini] FULL ERROR:' , e ) ;
if ( typeof result !== 'undefined' && result ? . response ) {
console . error ( '[Gemini] Raw response:' , result . response . text ( ) ) ;
}
return {
2026-03-20 14:01:18 +00:00
summary : 'AI分析に失敗しました。しばらく後にお試しください。' ,
2026-03-17 08:19:20 +00:00
topics : [ 'その他' ] ,
readingTime : 3
} ;
}
}
// ── DB 初期化 (起動時に自動実行) ─────────
async function initDB ( ) {
// 1. posimai_brain DB を作成(なければ)
const admin = new Pool ( {
host : process . env . DB _HOST || 'db' , port : parseInt ( process . env . DB _PORT || '5432' ) ,
user : process . env . DB _USER || 'gitea' , password : process . env . DB _PASSWORD || '' ,
database : 'gitea'
} ) ;
try {
await admin . query ( 'CREATE DATABASE posimai_brain' ) ;
console . log ( '[DB] Created database posimai_brain' ) ;
} catch ( e ) {
if ( e . code !== '42P04' ) throw e ;
console . log ( '[DB] Database already exists, skipping' ) ;
} finally { await admin . end ( ) ; }
// 2. テーブル定義 — 1文ずつ実行( マルチステートメントのエラーを防止)
const schema = [
` CREATE TABLE IF NOT EXISTS users (
user _id VARCHAR ( 50 ) PRIMARY KEY ,
name VARCHAR ( 100 ) NOT NULL ,
created _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
` CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY ,
user _id VARCHAR ( 50 ) NOT NULL REFERENCES users ( user _id ) ON DELETE CASCADE ,
url TEXT NOT NULL ,
title TEXT ,
summary TEXT ,
topics TEXT [ ] DEFAULT '{}' ,
source VARCHAR ( 100 ) ,
status VARCHAR ( 20 ) DEFAULT 'inbox' ,
previous _status VARCHAR ( 20 ) DEFAULT 'inbox' ,
reading _time INT DEFAULT 3 ,
favicon TEXT ,
og _image TEXT ,
saved _at TIMESTAMPTZ DEFAULT NOW ( ) ,
read _at TIMESTAMPTZ ,
full _text TEXT ,
UNIQUE ( user _id , url )
) ` ,
` CREATE INDEX IF NOT EXISTS idx_articles_user_id ON articles(user_id) ` ,
` CREATE INDEX IF NOT EXISTS idx_articles_saved_at ON articles(saved_at DESC) ` ,
` CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(user_id, status) ` ,
` CREATE INDEX IF NOT EXISTS idx_articles_topics ON articles USING GIN(topics) ` ,
` CREATE TABLE IF NOT EXISTS reading_history (
id SERIAL PRIMARY KEY ,
user _id VARCHAR ( 50 ) NOT NULL ,
url TEXT NOT NULL ,
title TEXT ,
domain VARCHAR ( 200 ) ,
read _at TIMESTAMPTZ DEFAULT NOW ( ) ,
UNIQUE ( user _id , url )
) ` ,
` CREATE TABLE IF NOT EXISTS journal_posts (
id SERIAL PRIMARY KEY ,
user _id VARCHAR ( 50 ) NOT NULL REFERENCES users ( user _id ) ON DELETE CASCADE ,
title TEXT NOT NULL DEFAULT '' ,
body TEXT NOT NULL DEFAULT '' ,
tags TEXT [ ] DEFAULT '{}' ,
created _at TIMESTAMPTZ DEFAULT NOW ( ) ,
updated _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
` CREATE INDEX IF NOT EXISTS idx_journal_user_id ON journal_posts(user_id) ` ,
` CREATE INDEX IF NOT EXISTS idx_journal_updated ON journal_posts(updated_at DESC) ` ,
// site_config: CMS サイト設定テーブル
` CREATE TABLE IF NOT EXISTS site_config (
key VARCHAR ( 50 ) PRIMARY KEY ,
value JSONB NOT NULL DEFAULT '{}' ,
updated _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
// ── Habit ──────────────────────────────
` CREATE TABLE IF NOT EXISTS habit_habits (
id SERIAL PRIMARY KEY ,
user _id VARCHAR ( 50 ) NOT NULL REFERENCES users ( user _id ) ON DELETE CASCADE ,
name VARCHAR ( 100 ) NOT NULL ,
icon VARCHAR ( 50 ) NOT NULL DEFAULT 'check' ,
position INTEGER NOT NULL DEFAULT 0 ,
created _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
` CREATE TABLE IF NOT EXISTS habit_log (
user _id VARCHAR ( 50 ) NOT NULL ,
habit _id INTEGER NOT NULL REFERENCES habit _habits ( id ) ON DELETE CASCADE ,
log _date DATE NOT NULL ,
PRIMARY KEY ( user _id , habit _id , log _date )
) ` ,
// ── Pulse ──────────────────────────────
` CREATE TABLE IF NOT EXISTS pulse_log (
user _id VARCHAR ( 50 ) NOT NULL ,
log _date DATE NOT NULL ,
mood SMALLINT CHECK ( mood BETWEEN 1 AND 5 ) ,
energy SMALLINT CHECK ( energy BETWEEN 1 AND 5 ) ,
focus SMALLINT CHECK ( focus BETWEEN 1 AND 5 ) ,
note TEXT NOT NULL DEFAULT '' ,
updated _at TIMESTAMPTZ DEFAULT NOW ( ) ,
PRIMARY KEY ( user _id , log _date )
) ` ,
// ── Lens ───────────────────────────────
` CREATE TABLE IF NOT EXISTS lens_history (
id SERIAL PRIMARY KEY ,
user _id VARCHAR ( 50 ) NOT NULL ,
filename VARCHAR ( 255 ) ,
exif _data JSONB NOT NULL DEFAULT '{}' ,
thumbnail TEXT ,
scanned _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
// ── Together ────────────────────────────
` CREATE TABLE IF NOT EXISTS together_groups (
id SERIAL PRIMARY KEY ,
invite _code VARCHAR ( 8 ) UNIQUE NOT NULL ,
name VARCHAR ( 100 ) NOT NULL ,
created _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
` CREATE TABLE IF NOT EXISTS together_members (
group _id INTEGER REFERENCES together _groups ( id ) ON DELETE CASCADE ,
username VARCHAR ( 50 ) NOT NULL ,
joined _at TIMESTAMPTZ DEFAULT NOW ( ) ,
PRIMARY KEY ( group _id , username )
) ` ,
` CREATE TABLE IF NOT EXISTS together_shares (
id SERIAL PRIMARY KEY ,
group _id INTEGER REFERENCES together _groups ( id ) ON DELETE CASCADE ,
shared _by VARCHAR ( 50 ) NOT NULL ,
url TEXT ,
title TEXT ,
message TEXT NOT NULL DEFAULT '' ,
og _image TEXT ,
tags TEXT [ ] DEFAULT '{}' ,
full _content TEXT ,
summary TEXT ,
archive _status VARCHAR ( 10 ) NOT NULL DEFAULT 'pending' ,
shared _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
` CREATE INDEX IF NOT EXISTS idx_together_shares_group ON together_shares(group_id, shared_at DESC) ` ,
` CREATE TABLE IF NOT EXISTS together_reactions (
share _id INTEGER REFERENCES together _shares ( id ) ON DELETE CASCADE ,
username VARCHAR ( 50 ) NOT NULL ,
type VARCHAR ( 20 ) NOT NULL DEFAULT 'like' ,
created _at TIMESTAMPTZ DEFAULT NOW ( ) ,
PRIMARY KEY ( share _id , username , type )
) ` ,
` CREATE TABLE IF NOT EXISTS together_comments (
id SERIAL PRIMARY KEY ,
share _id INTEGER REFERENCES together _shares ( id ) ON DELETE CASCADE ,
username VARCHAR ( 50 ) NOT NULL ,
body TEXT NOT NULL ,
created _at TIMESTAMPTZ DEFAULT NOW ( )
) ` ,
` CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at) ` ,
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-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-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-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' , '*' ) ;
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 ( _ ) { }
res . json ( {
status : 'ok' ,
gemini : ! ! genAI ,
users : Object . values ( KEY _MAP ) . length ,
hostname : os . hostname ( ) ,
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 ,
platform : os . platform ( ) ,
node _version : process . version ,
timestamp : new Date ( ) . toISOString ( ) ,
} ) ;
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 ) => {
const { email } = req . body ;
if ( ! email || ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/ . test ( email ) ) {
return res . status ( 400 ) . json ( { error : 'メールアドレスが無効です' } ) ;
}
const normalizedEmail = email . toLowerCase ( ) . trim ( ) ;
try {
// Rate limit: 3 requests per 10 min per email
const rl = await pool . query (
` SELECT attempt_count, window_start FROM magic_link_rate_limit WHERE email = $ 1 ` ,
[ normalizedEmail ]
) ;
if ( rl . rows . length > 0 ) {
const row = rl . rows [ 0 ] ;
const windowAgeMinutes = ( Date . now ( ) - new Date ( row . window _start ) . getTime ( ) ) / 60000 ;
if ( windowAgeMinutes < 10 && row . attempt _count >= 3 ) {
return res . status ( 429 ) . json ( { error : '送信制限: 10分後に再試行してください' } ) ;
}
if ( windowAgeMinutes >= 10 ) {
await pool . query (
` UPDATE magic_link_rate_limit SET attempt_count = 1, window_start = NOW() WHERE email = $ 1 ` ,
[ normalizedEmail ]
) ;
} else {
await pool . query (
` UPDATE magic_link_rate_limit SET attempt_count = attempt_count + 1 WHERE email = $ 1 ` ,
[ normalizedEmail ]
) ;
}
} else {
await pool . query (
` INSERT INTO magic_link_rate_limit (email) VALUES ( $ 1) ` ,
[ normalizedEmail ]
) ;
}
// Generate token
const token = crypto . randomBytes ( 32 ) . toString ( 'hex' ) ;
const expiresAt = new Date ( Date . now ( ) + 15 * 60 * 1000 ) ; // 15 min
await pool . query (
` INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ( $ 1, $ 2, $ 3) ` ,
[ normalizedEmail , token , expiresAt ]
) ;
// Send email via Resend (if API key is set)
if ( process . env . RESEND _API _KEY ) {
const magicLinkUrl = ` ${ MAGIC _LINK _BASE _URL } /auth/verify?token= ${ token } ` ;
try {
const emailRes = await fetch ( 'https://api.resend.com/emails' , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : ` Bearer ${ process . env . RESEND _API _KEY } `
} ,
body : JSON . stringify ( {
from : 'Posimai <hello@soar-enrich.com>' ,
to : [ normalizedEmail ] ,
subject : 'Posimai ログインリンク' ,
html : ` <p>以下のリンクをクリックして Posimai にログインしてください。</p>
< p > < a href = "${magicLinkUrl}" style = "font-size:16px;font-weight:bold;" > $ { magicLinkUrl } < / a > < / p >
< p > このリンクは15分間有効です 。 < / p >
< p > このメールに心当たりがない場合は無視してください 。 < / p > `
} )
} ) ;
if ( ! emailRes . ok ) {
const errBody = await emailRes . text ( ) ;
console . error ( '[Auth] Resend API error:' , emailRes . status , errBody ) ;
} else {
console . log ( ` [Auth] Magic link sent to ${ normalizedEmail } ` ) ;
}
} catch ( emailErr ) {
console . error ( '[Auth] Email send failed:' , emailErr . message ) ;
}
} else {
// Dev mode: log token to console
console . log ( ` [Auth] Magic link token (dev): ${ token } ` ) ;
}
res . json ( { ok : true , message : 'ログインリンクを送信しました' } ) ;
} catch ( e ) {
console . error ( '[Auth] Magic link send error:' , e ) ;
res . status ( 500 ) . json ( { error : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
// GET /api/auth/magic-link/verify?token=xxx
r . get ( '/auth/magic-link/verify' , async ( req , res ) => {
const { token } = req . query ;
if ( ! token ) return res . status ( 400 ) . json ( { error : 'トークンが必要です' } ) ;
try {
const result = await pool . query (
` SELECT * FROM magic_link_tokens WHERE token = $ 1 AND used_at IS NULL AND expires_at > NOW() ` ,
[ token ]
) ;
if ( result . rows . length === 0 ) {
return res . status ( 401 ) . json ( { error : 'トークンが無効または期限切れです' } ) ;
}
const { email } = result . rows [ 0 ] ;
// Mark token as used
await pool . query (
` UPDATE magic_link_tokens SET used_at = NOW() WHERE token = $ 1 ` ,
[ token ]
) ;
// Find or create user by email
let userResult = await pool . query (
` SELECT user_id FROM users WHERE email = $ 1 ` ,
[ email ]
) ;
let userId ;
if ( userResult . rows . length > 0 ) {
userId = userResult . rows [ 0 ] . user _id ;
await pool . query (
` UPDATE users SET email_verified = true WHERE user_id = $ 1 ` ,
[ userId ]
) ;
} else {
// Generate user_id from email prefix
const baseId = email . split ( '@' ) [ 0 ] . replace ( /[^a-zA-Z0-9_-]/g , '' ) . substring ( 0 , 30 ) ;
userId = ` ${ baseId } _ ${ Date . now ( ) . toString ( 36 ) } ` ;
await pool . query (
` INSERT INTO users (user_id, name, email, email_verified) VALUES ( $ 1, $ 2, $ 3, true)
ON CONFLICT ( user _id ) DO NOTHING ` ,
[ userId , baseId , email ]
) ;
}
const sessionToken = await createSessionJWT ( userId ) ;
res . json ( { ok : true , token : sessionToken , userId } ) ;
} catch ( e ) {
console . error ( '[Auth] Magic link verify error:' , e ) ;
res . status ( 500 ) . json ( { error : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
// GET /api/auth/session/verify — check current JWT
r . get ( '/auth/session/verify' , authMiddleware , ( req , res ) => {
res . json ( { ok : true , userId : req . userId , authType : req . authType } ) ;
} ) ;
// DELETE /api/auth/session — logout (revoke session in DB)
r . delete ( '/auth/session' , authMiddleware , async ( req , res ) => {
try {
if ( req . authType === 'session' ) {
const auth = req . headers . authorization || '' ;
const token = auth . substring ( 7 ) . trim ( ) ;
try {
const payload = jwt . decode ( token ) ;
if ( payload ? . sid ) {
await pool . query (
` DELETE FROM auth_sessions WHERE id = $ 1 ` ,
[ payload . sid ]
) ;
}
} catch ( _ ) { /* ignore decode errors */ }
}
res . json ( { ok : true } ) ;
} catch ( e ) {
console . error ( '[Auth] Session delete error:' , e ) ;
res . status ( 500 ) . json ( { error : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
// ── Auth: Passkey / WebAuthn ─────────────────────────────────────
// POST /api/auth/passkey/register/begin (requires existing session)
r . post ( '/auth/passkey/register/begin' , authMiddleware , async ( req , res ) => {
if ( ! webauthn ) return res . status ( 503 ) . json ( { error : 'WebAuthn not available' } ) ;
const userId = req . userId ;
try {
// Get or create stable user handle (random bytes, not user_id)
let handleResult = await pool . query (
` SELECT user_handle FROM webauthn_user_handles WHERE user_id = $ 1 ` ,
[ userId ]
) ;
let userHandle ;
if ( handleResult . rows . length > 0 ) {
userHandle = handleResult . rows [ 0 ] . user _handle ;
} else {
userHandle = crypto . randomBytes ( 16 ) . toString ( 'base64url' ) ;
await pool . query (
` INSERT INTO webauthn_user_handles (user_id, user_handle) VALUES ( $ 1, $ 2)
ON CONFLICT ( user _id ) DO NOTHING ` ,
[ userId , userHandle ]
) ;
}
// Get existing credentials (to exclude from registration options)
const existing = await pool . query (
` SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $ 1 ` ,
[ userId ]
) ;
const excludeCredentials = existing . rows . map ( row => ( {
id : row . credential _id ,
transports : row . transports || [ ]
} ) ) ;
// Get user info for display name
const userInfo = await pool . query ( ` SELECT name, email FROM users WHERE user_id = $ 1 ` , [ userId ] ) ;
const displayName = userInfo . rows [ 0 ] ? . email || userId ;
const options = await webauthn . generateRegistrationOptions ( {
rpName : WEBAUTHN _RP _NAME ,
rpID : WEBAUTHN _RP _ID ,
userID : Buffer . from ( userHandle ) ,
userName : userId ,
userDisplayName : displayName ,
attestationType : 'none' ,
excludeCredentials ,
authenticatorSelection : {
residentKey : 'preferred' ,
userVerification : 'preferred'
}
} ) ;
// Store challenge (5 min TTL)
webauthnChallenges . set ( ` reg: ${ userId } ` , {
challenge : options . challenge ,
expiresAt : Date . now ( ) + 5 * 60 * 1000
} ) ;
res . json ( options ) ;
} catch ( e ) {
console . error ( '[Auth] Passkey register begin error:' , e ) ;
res . status ( 500 ) . json ( { error : 'パスキー登録の開始に失敗しました' } ) ;
}
} ) ;
// POST /api/auth/passkey/register/finish (requires existing session)
r . post ( '/auth/passkey/register/finish' , authMiddleware , async ( req , res ) => {
if ( ! webauthn ) return res . status ( 503 ) . json ( { error : 'WebAuthn not available' } ) ;
const userId = req . userId ;
const challengeEntry = webauthnChallenges . get ( ` reg: ${ userId } ` ) ;
if ( ! challengeEntry || challengeEntry . expiresAt < Date . now ( ) ) {
return res . status ( 400 ) . json ( { error : '登録セッションが期限切れです。最初からやり直してください' } ) ;
}
try {
const verification = await webauthn . verifyRegistrationResponse ( {
response : req . body ,
expectedChallenge : challengeEntry . challenge ,
expectedOrigin : WEBAUTHN _ORIGINS ,
expectedRPID : WEBAUTHN _RP _ID
} ) ;
if ( ! verification . verified || ! verification . registrationInfo ) {
return res . status ( 400 ) . json ( { error : 'パスキーの検証に失敗しました' } ) ;
}
webauthnChallenges . delete ( ` reg: ${ userId } ` ) ;
const { credential , credentialDeviceType } = verification . registrationInfo ;
const displayName = req . body . displayName || req . headers [ 'user-agent' ] ? . substring ( 0 , 50 ) || 'Unknown device' ;
await pool . query (
` INSERT INTO passkey_credentials
( user _id , credential _id , public _key , counter , device _type , transports , display _name )
VALUES ( $1 , $2 , $3 , $4 , $5 , $6 , $7 )
ON CONFLICT ( credential _id ) DO UPDATE
SET counter = $4 , last _used _at = NOW ( ) ` ,
[
userId ,
credential . id ,
Buffer . from ( credential . publicKey ) ,
credential . counter ,
credentialDeviceType ,
req . body . response ? . transports || [ ] ,
displayName
]
) ;
res . json ( { ok : true } ) ;
} catch ( e ) {
console . error ( '[Auth] Passkey register finish error:' , e ) ;
res . status ( 500 ) . json ( { error : 'パスキー登録に失敗しました' } ) ;
}
} ) ;
// POST /api/auth/passkey/login/begin
r . post ( '/auth/passkey/login/begin' , async ( req , res ) => {
if ( ! webauthn ) return res . status ( 503 ) . json ( { error : 'WebAuthn not available' } ) ;
const { email } = req . body ;
const challengeKey = email ? ` login: ${ email . toLowerCase ( ) . trim ( ) } ` : ` login:anon: ${ crypto . randomBytes ( 8 ) . toString ( 'hex' ) } ` ;
try {
let allowCredentials = [ ] ;
if ( email ) {
const normalizedEmail = email . toLowerCase ( ) . trim ( ) ;
const userResult = await pool . query (
` SELECT u.user_id FROM users u WHERE u.email = $ 1 ` ,
[ normalizedEmail ]
) ;
if ( userResult . rows . length > 0 ) {
const userId = userResult . rows [ 0 ] . user _id ;
const creds = await pool . query (
` SELECT credential_id, transports FROM passkey_credentials WHERE user_id = $ 1 ` ,
[ userId ]
) ;
allowCredentials = creds . rows . map ( row => ( {
id : row . credential _id ,
transports : row . transports || [ ]
} ) ) ;
}
}
const options = await webauthn . generateAuthenticationOptions ( {
rpID : WEBAUTHN _RP _ID ,
allowCredentials ,
userVerification : 'preferred'
} ) ;
webauthnChallenges . set ( challengeKey , {
challenge : options . challenge ,
email : email ? email . toLowerCase ( ) . trim ( ) : null ,
expiresAt : Date . now ( ) + 5 * 60 * 1000
} ) ;
res . json ( { ... options , _challengeKey : challengeKey } ) ;
} catch ( e ) {
console . error ( '[Auth] Passkey login begin error:' , e ) ;
res . status ( 500 ) . json ( { error : 'パスキーログインの開始に失敗しました' } ) ;
}
} ) ;
// POST /api/auth/passkey/login/finish
r . post ( '/auth/passkey/login/finish' , async ( req , res ) => {
if ( ! webauthn ) return res . status ( 503 ) . json ( { error : 'WebAuthn not available' } ) ;
const { _challengeKey , ... assertionResponse } = req . body ;
if ( ! _challengeKey ) return res . status ( 400 ) . json ( { error : 'challengeKey が必要です' } ) ;
const challengeEntry = webauthnChallenges . get ( _challengeKey ) ;
if ( ! challengeEntry || challengeEntry . expiresAt < Date . now ( ) ) {
return res . status ( 400 ) . json ( { error : 'ログインセッションが期限切れです。最初からやり直してください' } ) ;
}
try {
// Find credential in DB
const credResult = await pool . query (
` SELECT c.*, c.user_id FROM passkey_credentials c WHERE c.credential_id = $ 1 ` ,
[ assertionResponse . id ]
) ;
if ( credResult . rows . length === 0 ) {
return res . status ( 401 ) . json ( { error : 'パスキーが見つかりません' } ) ;
}
const cred = credResult . rows [ 0 ] ;
const verification = await webauthn . verifyAuthenticationResponse ( {
response : assertionResponse ,
expectedChallenge : challengeEntry . challenge ,
expectedOrigin : WEBAUTHN _ORIGINS ,
expectedRPID : WEBAUTHN _RP _ID ,
credential : {
id : cred . credential _id ,
publicKey : new Uint8Array ( cred . public _key ) ,
counter : Number ( cred . counter ) ,
transports : cred . transports || [ ]
}
} ) ;
if ( ! verification . verified ) {
return res . status ( 401 ) . json ( { error : 'パスキーの検証に失敗しました' } ) ;
}
webauthnChallenges . delete ( _challengeKey ) ;
// Update counter and last_used_at
await pool . query (
` UPDATE passkey_credentials SET counter = $ 1, last_used_at = NOW() WHERE credential_id = $ 2 ` ,
[ verification . authenticationInfo . newCounter , cred . credential _id ]
) ;
const sessionToken = await createSessionJWT ( cred . user _id ) ;
res . json ( { ok : true , token : sessionToken , userId : cred . user _id } ) ;
} catch ( e ) {
console . error ( '[Auth] Passkey login finish error:' , e ) ;
res . status ( 500 ) . json ( { error : 'パスキーログインに失敗しました' } ) ;
}
} ) ;
// GET /api/auth/passkeys — list user's registered passkeys
r . get ( '/auth/passkeys' , authMiddleware , async ( req , res ) => {
try {
const result = await pool . query (
` SELECT id, credential_id, device_type, transports, display_name, created_at, last_used_at
FROM passkey _credentials WHERE user _id = $1 ORDER BY created _at DESC ` ,
[ req . userId ]
) ;
res . json ( { passkeys : result . rows } ) ;
} catch ( e ) {
console . error ( '[Auth] List passkeys error:' , e ) ;
res . status ( 500 ) . json ( { error : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
// DELETE /api/auth/passkeys/:id — remove a passkey
r . delete ( '/auth/passkeys/:id' , authMiddleware , async ( req , res ) => {
try {
const result = await pool . query (
` DELETE FROM passkey_credentials WHERE id = $ 1 AND user_id = $ 2 RETURNING id ` ,
[ req . params . id , req . userId ]
) ;
if ( result . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'パスキーが見つかりません' } ) ;
}
res . json ( { ok : true } ) ;
} catch ( e ) {
console . error ( '[Auth] Delete passkey error:' , e ) ;
res . status ( 500 ) . json ( { error : 'サーバーエラーが発生しました' } ) ;
}
} ) ;
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 {
const { rows } = await pool . query ( sql , params ) ;
// カウント計算
const counts = {
all : rows . length ,
unread : rows . filter ( a => a . status === 'inbox' ) . length ,
favorite : rows . filter ( a => a . status === 'favorite' ) . length ,
shared : rows . filter ( a => a . status === 'shared' ) . length
} ;
res . json ( { articles : rows , counts } ) ;
} catch ( e ) { console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
// 記事詳細取得(full_text を含む完全版)
r . get ( '/articles/:id' , authMiddleware , async ( req , res ) => {
try {
const { rows } = await pool . query (
'SELECT * FROM articles WHERE id=$1 AND user_id=$2' ,
[ req . params . id , req . userId ]
) ;
if ( rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Article not found' } ) ;
return res . json ( { article : rows [ 0 ] } ) ;
} catch ( e ) {
console . error ( '[Brain API] GET /articles/:id failed:' , e . stack || e ) ;
return res . status ( 500 ) . json ( { error : 'DB error' } ) ;
}
} ) ;
// ========== 記事保存( Jina Reader自動取得対応) ==========
r . post ( '/save' , authMiddleware , async ( req , res ) => {
const { url , title : clientTitle , content , source : clientSource } = req . body || { } ;
if ( ! url ) return res . status ( 400 ) . json ( { error : 'url is required' } ) ;
let parsedUrl ;
try { parsedUrl = new URL ( url ) ; } catch { return res . status ( 400 ) . json ( { error : 'Invalid URL' } ) ; }
if ( ! [ 'http:' , 'https:' ] . includes ( parsedUrl . protocol ) )
return res . status ( 400 ) . json ( { error : 'Only http/https' } ) ;
try {
const meta = await fetchMeta ( url ) ;
let fullText = content || null ;
const source = clientSource || extractSource ( url ) ;
2026-03-17 09:10:55 +00:00
// 重要: contentが空の場合、Jina Reader APIで本文を自動取得
2026-03-17 08:19:20 +00:00
if ( ! fullText || fullText . trim ( ) . length === 0 ) {
console . log ( ` [Brain API] No content provided for ${ url } , attempting Jina Reader fetch... ` ) ;
const jinaText = await fetchFullTextViaJina ( url ) ;
if ( jinaText && jinaText . length > 0 ) {
fullText = jinaText ;
console . log ( ` [Brain API] ✓ Using Jina Reader full text ( ${ fullText . length } chars) ` ) ;
} else {
// Jina Reader失敗時はOGP descriptionをフォールバック
console . log ( ` [Brain API] ⚠ Jina Reader failed, falling back to OGP description ` ) ;
fullText = meta . desc || '' ;
}
} else {
console . log ( ` [Brain API] Using provided content ( ${ fullText . length } chars) ` ) ;
}
// 即座に保存してフロントに返す( AIはバックグラウンド)
let articleQuery = await pool . query ( `
INSERT INTO articles ( user _id , url , title , full _text , summary , topics , source , reading _time , favicon , og _image )
VALUES ( $1 , $2 , $3 , $4 , $5 , $6 , $7 , $8 , $9 , $10 )
ON CONFLICT ( user _id , url ) DO UPDATE
SET title = EXCLUDED . title , full _text = EXCLUDED . full _text , source = EXCLUDED . source , summary = '⏳ 再分析中...'
RETURNING *
` , [req.userId, url, clientTitle || meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
let article = articleQuery . rows [ 0 ] ;
res . json ( { ok : true , article , aiStatus : 'pending' } ) ;
// バックグラウンドでAI処理
analyzeWithGemini ( clientTitle || meta . title , fullText || meta . desc , url ) . then ( async ( ai ) => {
await pool . query ( `
UPDATE articles SET summary = $1 , topics = $2 , reading _time = $3
WHERE user _id = $4 AND url = $5
` , [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
console . log ( ` [Brain API] ✓ AI analysis completed for ${ url } ` ) ;
} ) . catch ( e => console . error ( '[Background AI Error]:' , e ) ) ;
} catch ( e ) {
if ( e . code === '23505' ) return res . status ( 409 ) . json ( { error : 'すでに保存済みです' } ) ;
console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ;
}
} ) ;
// ステータス更新
r . patch ( '/articles/:id/status' , authMiddleware , async ( req , res ) => {
const { status } = req . body || { } ;
const valid = [ 'inbox' , 'favorite' , 'shared' ] ;
if ( ! valid . includes ( status ) ) return res . status ( 400 ) . json ( { error : 'Invalid status' } ) ;
try {
const readAt = status === 'shared' || status === 'reading' ? 'NOW()' : 'NULL' ;
if ( status === 'favorite' ) {
await pool . query (
` UPDATE articles SET previous_status=status, status= $ 1, read_at= ${ readAt === 'NULL' ? 'NULL' : 'NOW()' }
WHERE id = $2 AND user _id = $3 ` ,
[ status , req . params . id , req . userId ]
) ;
} else {
await pool . query (
` UPDATE articles SET status= $ 1, read_at= ${ readAt === 'NULL' ? 'NULL' : 'NOW()' }
WHERE id = $2 AND user _id = $3 ` ,
[ status , req . params . id , req . userId ]
) ;
}
res . json ( { ok : true } ) ;
} catch ( e ) { res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
// 削除
r . delete ( '/articles/:id' , authMiddleware , async ( req , res ) => {
try {
await pool . query ( 'DELETE FROM articles WHERE id=$1 AND user_id=$2' , [ req . params . id , req . userId ] ) ;
res . json ( { ok : true } ) ;
} catch ( e ) { res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
// クイック保存 (Bookmarklet等からのGET) — Jina Reader対応
r . get ( '/quick-save' , authMiddleware , async ( req , res ) => {
const url = req . query . url ;
if ( ! url ) return res . status ( 400 ) . send ( '<h1>URL not provided</h1>' ) ;
try {
const meta = await fetchMeta ( url ) ;
const source = extractSource ( url ) ;
// Jina Readerで本文取得を試みる
let fullText = await fetchFullTextViaJina ( url ) ;
if ( ! fullText || fullText . length === 0 ) {
fullText = meta . desc || '' ;
}
await pool . query ( `
INSERT INTO articles ( user _id , url , title , full _text , summary , topics , source , reading _time , favicon , og _image )
VALUES ( $1 , $2 , $3 , $4 , $5 , $6 , $7 , $8 , $9 , $10 )
ON CONFLICT ( user _id , url ) DO UPDATE
SET title = EXCLUDED . title , full _text = EXCLUDED . full _text , source = EXCLUDED . source , summary = '⏳ 再分析中...'
` , [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
// バックグラウンドAI
analyzeWithGemini ( meta . title , fullText , url ) . then ( async ( ai ) => {
await pool . query ( `
UPDATE articles SET summary = $1 , topics = $2 , reading _time = $3
WHERE user _id = $4 AND url = $5
` , [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
} ) . catch ( e => console . error ( '[Background AI Error]:' , e ) ) ;
// HTMLレスポンス( 自動で閉じる)
res . send ( `
< ! DOCTYPE html >
< html > < head > < meta charset = "utf-8" > < title > 保存完了 < / t i t l e > < / h e a d >
< body style = "font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2" >
< h1 style = "color:#818CF8" > ✓ 保存しました < / h 1 >
2026-04-02 23:15:45 +00:00
< p > $ { escapeHtml ( meta . title ) } < / p >
2026-03-17 08:19:20 +00:00
< p style = "color:#888" > AI分析をバックグラウンドで開始しました < / p >
< script > setTimeout ( ( ) => window . close ( ) , 1500 ) < / s c r i p t >
< / b o d y > < / h t m l >
` );
} catch ( e ) {
2026-04-02 23:15:45 +00:00
res . status ( 500 ) . send ( ` <h1>保存失敗: ${ escapeHtml ( e . message ) } </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' } ) ;
const { title = '' , body = '' } = req . body || { } ;
if ( ! title && ! body ) return res . status ( 400 ) . json ( { error : 'title or body required' } ) ;
try {
const model = genAI . getGenerativeModel ( {
model : 'gemini-2.0-flash-lite' ,
generationConfig : { responseMimeType : 'application/json' }
} ) ;
const excerpt = smartExtract ( body , 2000 ) ;
const prompt = ` 以下の記事にふさわしい日本語タグを3〜5個提案してください。
タグは短い単語または短いフレーズ ( 例 : "Next.js" , "インフラ" , "開発メモ" , "Tailscale" ) 。
既存のタグと重複してもOK 。 結果はJSONで返してください 。
タイトル : $ { title . slice ( 0 , 80 ) }
本文抜粋 :
$ { excerpt }
{ "tags" : [ "タグ1" , "タグ2" , "タグ3" ] } ` ;
const timeoutPromise = new Promise ( ( _ , reject ) =>
setTimeout ( ( ) => reject ( new Error ( 'timeout' ) ) , 12000 )
) ;
const result = await Promise . race ( [ model . generateContent ( prompt ) , timeoutPromise ] ) ;
let raw = result . response . text ( ) . trim ( )
. replace ( /^```(json)?/i , '' ) . replace ( /```$/i , '' ) . trim ( ) ;
const parsed = JSON . parse ( raw ) ;
const tags = ( parsed . tags || [ ] ) . filter ( t => typeof t === 'string' && t . length <= 30 ) . slice ( 0 , 5 ) ;
res . json ( { tags } ) ;
} catch ( e ) {
console . error ( '[suggest-tags]' , e . message ) ;
res . status ( 500 ) . json ( { error : 'AI suggestion failed' } ) ;
}
} ) ;
r . post ( '/journal/upload' , authMiddleware , ( req , res ) => {
try {
const { base64 } = req . body || { } ;
if ( ! base64 ) return res . status ( 400 ) . json ( { error : 'base64 required' } ) ;
const match = base64 . match ( /^data:(image\/(jpeg|png|gif|webp));base64,(.+)$/ ) ;
if ( ! match ) return res . status ( 400 ) . json ( { error : 'Invalid image format' } ) ;
const ext = match [ 2 ] === 'jpeg' ? 'jpg' : match [ 2 ] ;
const buffer = Buffer . from ( match [ 3 ] , 'base64' ) ;
if ( buffer . length > 5 * 1024 * 1024 ) {
return res . status ( 400 ) . json ( { error : 'File too large (max 5MB)' } ) ;
}
const name = crypto . randomBytes ( 10 ) . toString ( 'hex' ) + '.' + ext ;
fs . writeFileSync ( path . join ( UPLOADS _DIR , name ) , buffer ) ;
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 {
const result = await pool . query (
'SELECT id, name, feed_url, site_url, category, is_active, created_at FROM feed_media WHERE user_id=$1 ORDER BY created_at ASC' ,
[ req . userId ]
) ;
res . json ( result . rows ) ;
} catch ( e ) { console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
r . post ( '/feed/media' , authMiddleware , async ( req , res ) => {
const { name , feed _url , site _url = '' , category = 'tech' , is _active = true } = req . body || { } ;
if ( ! name || ! feed _url ) return res . status ( 400 ) . json ( { error : 'name and feed_url required' } ) ;
try {
const result = await pool . query (
'INSERT INTO feed_media (user_id, name, feed_url, site_url, category, is_active) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, name, feed_url, site_url, category, is_active, created_at' ,
[ req . userId , name , feed _url , site _url , category , is _active ]
) ;
res . status ( 201 ) . json ( result . rows [ 0 ] ) ;
} catch ( e ) { console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
r . patch ( '/feed/media/:id' , authMiddleware , async ( req , res ) => {
const { name , feed _url , site _url , category , is _active } = req . body || { } ;
try {
const fields = [ ] ;
const vals = [ ] ;
let idx = 1 ;
if ( name !== undefined ) { fields . push ( ` name= $ ${ idx ++ } ` ) ; vals . push ( name ) ; }
if ( feed _url !== undefined ) { fields . push ( ` feed_url= $ ${ idx ++ } ` ) ; vals . push ( feed _url ) ; }
if ( site _url !== undefined ) { fields . push ( ` site_url= $ ${ idx ++ } ` ) ; vals . push ( site _url ) ; }
if ( category !== undefined ) { fields . push ( ` category= $ ${ idx ++ } ` ) ; vals . push ( category ) ; }
if ( is _active !== undefined ) { fields . push ( ` is_active= $ ${ idx ++ } ` ) ; vals . push ( is _active ) ; }
if ( fields . length === 0 ) return res . status ( 400 ) . json ( { error : 'no fields to update' } ) ;
vals . push ( req . params . id , req . userId ) ;
const result = await pool . query (
` UPDATE feed_media SET ${ fields . join ( ',' ) } WHERE id= $ ${ idx } AND user_id= $ ${ idx + 1 } RETURNING id, name, feed_url, site_url, category, is_active ` ,
vals
) ;
if ( result . rowCount === 0 ) return res . status ( 404 ) . json ( { error : 'not found' } ) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( e ) { console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
r . delete ( '/feed/media/:id' , authMiddleware , async ( req , res ) => {
try {
await pool . query (
'DELETE FROM feed_media WHERE id=$1 AND user_id=$2' ,
[ req . params . id , req . userId ]
) ;
res . json ( { ok : true } ) ;
} catch ( e ) { console . error ( e ) ; res . status ( 500 ) . json ( { error : 'DB error' } ) ; }
} ) ;
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 ( ) ) ;
}
// POST /tts — テキストを音声( WAV) に変換して返す
r . post ( '/tts' , authMiddleware , async ( req , res ) => {
const { text , speaker = 1 } = req . body || { } ;
if ( ! text || typeof text !== 'string' ) return res . status ( 400 ) . json ( { error : 'text required' } ) ;
if ( text . length > 600 ) return res . status ( 400 ) . json ( { error : 'text too long (max 600 chars)' } ) ;
const cacheKey = ` ${ speaker } : ${ text } ` ;
if ( ttsCache . has ( cacheKey ) ) {
const cached = ttsCache . get ( cacheKey ) ;
res . setHeader ( 'Content-Type' , 'audio/wav' ) ;
res . setHeader ( 'Content-Length' , cached . length ) ;
res . setHeader ( 'X-TTS-Cache' , 'HIT' ) ;
return res . send ( cached ) ;
}
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 — 指定テキストがキャッシュ済みか確認(ポーリング用)
r . post ( '/tts/ready' , authMiddleware , ( req , res ) => {
const { texts , speaker = 1 } = req . body || { } ;
if ( ! texts || ! Array . isArray ( texts ) ) return res . json ( { cached : 0 , total : 0 , ready : false } ) ;
const total = texts . length ;
const cached = texts . filter ( t => ttsCache . has ( ` ${ speaker } : ${ t } ` ) ) . length ;
res . json ( { cached , total , ready : cached === total } ) ;
} ) ;
// POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める
// ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。
r . post ( '/tts/warmup' , authMiddleware , async ( req , res ) => {
const { texts , speaker = 1 } = req . body || { } ;
if ( ! texts || ! Array . isArray ( texts ) || texts . length === 0 ) {
return res . status ( 400 ) . json ( { error : 'texts[] required' } ) ;
}
const valid = texts . filter ( t => typeof t === 'string' && t . length > 0 && t . length <= 600 ) ;
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 ) ;
res . status ( 500 ) . json ( { events : [ ] , error : err . message } ) ;
}
} ) ;
// ── Together ──────────────────────────────────────────────────────────────
// invite_code 生成( 8文字大文字16進)
function genInviteCode ( ) {
return crypto . randomBytes ( 4 ) . toString ( 'hex' ) . toUpperCase ( ) ;
}
// fire-and-forget アーカイブ: Jina Reader → Gemini 要約(直列)
async function archiveShare ( shareId , url ) {
if ( ! url ) {
await pool . query ( ` UPDATE together_shares SET archive_status='failed' WHERE id= $ 1 ` , [ shareId ] ) ;
return ;
}
try {
const jinaRes = await fetch ( ` https://r.jina.ai/ ${ url } ` , {
headers : { 'Accept' : 'text/plain' , 'User-Agent' : 'Posimai/1.0' } ,
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 } ` ) ;
const fullContent = await jinaRes . text ( ) ;
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 ) ;
const model = genAITogether . 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 {
const result = await pool . query ( 'SELECT * FROM together_groups WHERE id=$1' , [ req . params . groupId ] ) ;
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'グループが見つかりません' } ) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( e ) {
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// GET /together/members/:groupId — メンバー一覧
r . get ( '/together/members/:groupId' , async ( req , res ) => {
try {
const result = await pool . query (
'SELECT username, joined_at FROM together_members WHERE group_id=$1 ORDER BY joined_at' ,
[ req . params . groupId ]
) ;
res . json ( result . rows ) ;
} catch ( e ) {
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// POST /together/share — 記事・テキストをシェア(即返却 + 非同期アーカイブ)
r . post ( '/together/share' , async ( req , res ) => {
const { group _id , shared _by , url = null , title = null , message = '' , tags = [ ] } = req . body || { } ;
if ( ! group _id || ! shared _by ) return res . status ( 400 ) . json ( { error : 'group_id と shared_by は必須です' } ) ;
2026-03-17 08:26:59 +00:00
if ( url ) {
try { const p = new URL ( url ) ; if ( ! [ 'http:' , 'https:' ] . includes ( p . protocol ) ) throw new Error ( ) ; }
catch { return res . status ( 400 ) . json ( { error : 'url は http/https のみ有効です' } ) ; }
}
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 : 'グループが見つかりません' } ) ;
const result = await pool . query (
` INSERT INTO together_shares (group_id, shared_by, url, title, message, tags)
VALUES ( $1 , $2 , $3 , $4 , $5 , $6 ) RETURNING * ` ,
[ group _id , shared _by , url , title , message , tags ]
) ;
const share = result . rows [ 0 ] ;
res . json ( { ok : true , share } ) ;
// URL がある場合のみ非同期アーカイブ(ユーザーを待たせない)
if ( url ) archiveShare ( share . id , url ) ;
} catch ( e ) {
console . error ( '[together/share]' , e . message ) ;
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
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 {
const result = await pool . query (
'DELETE FROM together_shares WHERE id=$1 AND shared_by=$2 RETURNING id' ,
[ req . params . id , username ]
) ;
if ( result . rows . length === 0 ) return res . status ( 403 ) . json ( { error : '削除できません' } ) ;
res . json ( { ok : true } ) ;
} catch ( e ) {
console . error ( '[together/share DELETE]' , e . message ) ;
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
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 ) ;
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// GET /together/article/:shareId — アーカイブ本文取得
r . get ( '/together/article/:shareId' , async ( req , res ) => {
try {
const result = await pool . query (
'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1' ,
[ req . params . shareId ]
) ;
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : '見つかりません' } ) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( e ) {
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// POST /together/react — リアクション 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 {
const existing = await pool . query (
'SELECT 1 FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3' ,
[ share _id , username , type ]
) ;
if ( existing . rows . length > 0 ) {
await pool . query (
'DELETE FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3' ,
[ share _id , username , type ]
) ;
res . json ( { ok : true , action : 'removed' } ) ;
} else {
await pool . query (
'INSERT INTO together_reactions (share_id, username, type) VALUES ($1, $2, $3)' ,
[ share _id , username , type ]
) ;
res . json ( { ok : true , action : 'added' } ) ;
}
} catch ( e ) {
console . error ( '[together/react]' , e . message ) ;
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// GET /together/comments/:shareId — コメント一覧
r . get ( '/together/comments/:shareId' , async ( req , res ) => {
try {
const result = await pool . query (
'SELECT * FROM together_comments WHERE share_id=$1 ORDER BY created_at' ,
[ req . params . shareId ]
) ;
res . json ( result . rows ) ;
} catch ( e ) {
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// POST /together/comments — コメント投稿
r . post ( '/together/comments' , async ( req , res ) => {
const { share _id , username , body } = req . body || { } ;
if ( ! share _id || ! username || ! body ? . trim ( ) ) {
return res . status ( 400 ) . json ( { error : 'share_id, username, body は必須です' } ) ;
}
try {
const result = await pool . query (
'INSERT INTO together_comments (share_id, username, body) VALUES ($1, $2, $3) RETURNING *' ,
[ share _id , username , body . trim ( ) ]
) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( e ) {
console . error ( '[together/comments POST]' , e . message ) ;
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
// GET /together/search/:groupId — キーワード / タグ検索
r . get ( '/together/search/:groupId' , async ( req , res ) => {
const { q = '' , tag = '' } = req . query ;
if ( ! q && ! tag ) return res . status ( 400 ) . json ( { error : 'q または tag が必要です' } ) ;
try {
const keyword = q ? ` % ${ q } % ` : '' ;
const result = await pool . query ( `
SELECT id , shared _by , url , title , message , tags , summary , archive _status , shared _at
FROM together _shares
WHERE group _id = $1
AND (
( $2 != '' AND ( title ILIKE $2 OR message ILIKE $2 OR full _content ILIKE $2 ) )
OR ( $3 != '' AND $3 = ANY ( tags ) )
)
ORDER BY shared _at DESC
LIMIT 30
` , [req.params.groupId, keyword, tag]);
res . json ( result . rows ) ;
} catch ( e ) {
console . error ( '[together/search]' , e . message ) ;
res . status ( 500 ) . json ( { error : e . message } ) ;
}
} ) ;
2026-03-30 15:25:44 +00:00
// ── Atlas: GitHub scan proxy ───────────────────────────────────
r . get ( '/atlas/github-scan' , ( req , res ) => {
const token = req . query . token ;
const org = req . query . org || '' ;
if ( ! token ) return res . status ( 400 ) . json ( { error : 'token required' } ) ;
const https = require ( 'https' ) ;
function ghRequest ( path , cb ) {
const options = {
hostname : 'api.github.com' ,
path ,
method : 'GET' ,
family : 4 ,
headers : {
Authorization : ` Bearer ${ token } ` ,
Accept : 'application/vnd.github+json' ,
'User-Agent' : 'Posimai-Atlas/1.0' ,
'X-GitHub-Api-Version' : '2022-11-28' ,
} ,
timeout : 12000 ,
} ;
const r2 = https . request ( options , ( resp ) => {
let body = '' ;
resp . on ( 'data' , chunk => { body += chunk ; } ) ;
resp . on ( 'end' , ( ) => cb ( null , resp . statusCode , body ) ) ;
} ) ;
r2 . on ( 'timeout' , ( ) => { r2 . destroy ( ) ; cb ( new Error ( 'Timeout' ) ) ; } ) ;
r2 . on ( 'error' , cb ) ;
r2 . end ( ) ;
}
const orgPath = org ? ` /orgs/ ${ encodeURIComponent ( org ) } /repos?per_page=100&sort=updated ` : null ;
const userPath = ` /user/repos?per_page=100&sort=updated&affiliation=owner ` ;
function handleResult ( status , body ) {
if ( status !== 200 ) return res . status ( status ) . json ( { error : body } ) ;
try { res . json ( JSON . parse ( body ) ) ; }
catch ( e ) { res . status ( 500 ) . json ( { error : 'Invalid JSON' } ) ; }
}
if ( orgPath ) {
ghRequest ( orgPath , ( err , status , body ) => {
if ( err ) return res . status ( 500 ) . json ( { error : err . message } ) ;
// If org not accessible, fall back to user repos
if ( status === 404 || status === 403 ) {
ghRequest ( userPath , ( err2 , status2 , body2 ) => {
if ( err2 ) return res . status ( 500 ) . json ( { error : err2 . message } ) ;
// Signal to client that we fell back
if ( status2 === 200 ) {
try {
const data = JSON . parse ( body2 ) ;
return res . json ( { repos : data , fallback : true } ) ;
} catch ( e ) { return res . status ( 500 ) . json ( { error : 'Invalid JSON' } ) ; }
}
handleResult ( status2 , body2 ) ;
} ) ;
} else {
handleResult ( status , body ) ;
}
} ) ;
} else {
ghRequest ( userPath , ( err , status , body ) => {
if ( err ) return res . status ( 500 ) . json ( { error : err . message } ) ;
handleResult ( status , body ) ;
} ) ;
}
} ) ;
// ── Atlas: Vercel scan proxy ───────────────────────────────────
r . get ( '/atlas/vercel-scan' , ( req , res ) => {
const token = req . query . token ;
if ( ! token ) return res . status ( 400 ) . json ( { error : 'token required' } ) ;
const https = require ( 'https' ) ;
const options = {
hostname : 'api.vercel.com' ,
path : '/v9/projects?limit=100' ,
method : 'GET' ,
family : 4 ,
headers : {
Authorization : ` Bearer ${ token } ` ,
'User-Agent' : 'Posimai-Atlas/1.0' ,
} ,
timeout : 12000 ,
} ;
const req2 = https . request ( options , ( r2 ) => {
let body = '' ;
r2 . on ( 'data' , chunk => { body += chunk ; } ) ;
r2 . on ( 'end' , ( ) => {
if ( r2 . statusCode !== 200 ) return res . status ( r2 . statusCode ) . json ( { error : body } ) ;
try { res . json ( JSON . parse ( body ) ) ; }
catch ( e ) { res . status ( 500 ) . json ( { error : 'Invalid JSON' } ) ; }
} ) ;
} ) ;
req2 . on ( 'timeout' , ( ) => { req2 . destroy ( ) ; res . status ( 500 ) . json ( { error : 'Timeout' } ) ; } ) ;
req2 . on ( 'error' , ( e ) => { res . status ( 500 ) . json ( { error : e . message , code : e . code } ) ; } ) ;
req2 . end ( ) ;
} ) ;
// ── Atlas: Tailscale scan proxy ────────────────────────────────
r . get ( '/atlas/tailscale-scan' , ( req , res ) => {
const token = req . query . token ;
if ( ! token ) return res . status ( 400 ) . json ( { error : 'token required' } ) ;
const https = require ( 'https' ) ;
const options = {
hostname : 'api.tailscale.com' ,
path : '/api/v2/tailnet/-/devices' ,
method : 'GET' ,
family : 4 , // force IPv4; container IPv6 to tailscale times out
headers : {
Authorization : ` Bearer ${ token } ` ,
Accept : 'application/json' ,
'User-Agent' : 'Posimai-Atlas/1.0' ,
} ,
timeout : 12000 ,
} ;
const req2 = https . request ( options , ( r2 ) => {
let body = '' ;
r2 . on ( 'data' , chunk => { body += chunk ; } ) ;
r2 . on ( 'end' , ( ) => {
if ( r2 . statusCode !== 200 ) {
return res . status ( r2 . statusCode ) . json ( { error : body } ) ;
}
try {
res . json ( JSON . parse ( body ) ) ;
} catch ( e ) {
res . status ( 500 ) . json ( { error : 'Invalid JSON from Tailscale' } ) ;
}
} ) ;
} ) ;
req2 . on ( 'timeout' , ( ) => {
req2 . destroy ( ) ;
res . status ( 500 ) . json ( { error : 'Request timed out' } ) ;
} ) ;
req2 . on ( 'error' , ( e ) => {
console . error ( '[atlas/tailscale-scan] error:' , e . code , e . message ) ;
res . status ( 500 ) . json ( { error : e . message , code : e . code } ) ;
} ) ;
req2 . end ( ) ;
} ) ;
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 ) ) ;
// ── マウント( Tailscale経由と直接アクセスの両方対応)
const router = buildRouter ( ) ;
app . use ( '/brain/api' , router ) ; // Tailscale Funnel 経由
app . use ( '/api' , router ) ; // ローカル直接アクセス
// ── 起動 ──────────────────────────────────
const PORT = parseInt ( process . env . PORT || '8090' ) ;
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
} ) ;
} )
. 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 ) ;
} ) ;