From 0590d0995dab43b4b561440baee82ff594d1e79c Mon Sep 17 00:00:00 2001 From: posimai Date: Sun, 5 Apr 2026 02:22:18 +0900 Subject: [PATCH] feat: Stripe Webhook + purchase gate - Add POST /api/stripe/webhook (signature verification, no stripe SDK) - Add purchased_at + stripe_session_id columns to users table (migration) - Add purchaseMiddleware (apikey users bypass, JWT users check purchased_at) - Update /auth/session/verify to return purchased status Co-Authored-By: Claude Sonnet 4.6 --- server.js | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 4 deletions(-) diff --git a/server.js b/server.js index 9fc18b53..63b680ee 100644 --- a/server.js +++ b/server.js @@ -193,7 +193,7 @@ function authMiddleware(req, res, next) { } } - // API key (legacy) + // API key (internal users — skip purchase check) const userId = KEY_MAP[token]; if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' }); req.userId = userId; @@ -201,6 +201,27 @@ function authMiddleware(req, res, next) { next(); } +// 購入済みチェックミドルウェア(JWT セッションユーザーのみ適用) +// API キーユーザー(内部)はスキップ +async function purchaseMiddleware(req, res, next) { + if (req.authType === 'apikey') return next(); // 内部ユーザーはスキップ + try { + const result = await pool.query( + `SELECT purchased_at FROM users WHERE user_id = $1`, [req.userId] + ); + if (result.rows.length > 0 && result.rows[0].purchased_at) { + return next(); + } + return res.status(402).json({ + error: '購入が必要です', + store_url: 'https://posimai-store.vercel.app/index-c.html' + }); + } catch (e) { + console.error('[Purchase] DB error:', e.message); + return res.status(500).json({ error: 'サーバーエラーが発生しました' }); + } +} + // ── ソース抽出 ──────────────────────────── const SOURCE_MAP = { 'zenn.dev': 'Zenn', 'qiita.com': 'Qiita', @@ -615,6 +636,8 @@ async function initDB() { `ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMPTZ`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`, ]; for (const sql of migrations) { await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message)); @@ -817,9 +840,20 @@ function buildRouter() { } }); - // 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 }); + // GET /api/auth/session/verify — check current JWT + purchase status + r.get('/auth/session/verify', authMiddleware, async (req, res) => { + if (req.authType === 'apikey') { + return res.json({ ok: true, userId: req.userId, authType: req.authType, purchased: true }); + } + try { + const result = await pool.query( + `SELECT purchased_at FROM users WHERE user_id = $1`, [req.userId] + ); + const purchased = !!(result.rows[0]?.purchased_at); + res.json({ ok: true, userId: req.userId, authType: req.authType, purchased }); + } catch (e) { + res.json({ ok: true, userId: req.userId, authType: req.authType, purchased: false }); + } }); // ── Auth: Google OAuth ─────────────────────────────────────────── @@ -2675,6 +2709,88 @@ if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); app.use('/brain/api/uploads', express.static(UPLOADS_DIR)); app.use('/api/uploads', express.static(UPLOADS_DIR)); +// ── Stripe Webhook ──────────────────────────────────────────────────── +// rawBody が必要なため express.json() より前・router より前に配置 +const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; + +async function handleStripeWebhook(req, res) { + const sig = req.headers['stripe-signature']; + if (!STRIPE_WEBHOOK_SECRET) { + console.warn('[Stripe] STRIPE_WEBHOOK_SECRET not set — webhook disabled'); + return res.status(503).json({ error: 'Webhook not configured' }); + } + if (!sig) return res.status(400).json({ error: 'Missing stripe-signature' }); + + // 署名検証(stripe ライブラリ不使用・軽量実装) + let event; + try { + const crypto = require('crypto'); + const rawBody = req.body; // Buffer + const [, tsStr, , v1Sig] = sig.match(/t=(\d+),.*v1=([a-f0-9]+)/) || []; + if (!tsStr || !v1Sig) throw new Error('Invalid signature format'); + const tolerance = 300; // 5分 + if (Math.abs(Date.now() / 1000 - parseInt(tsStr)) > tolerance) { + throw new Error('Timestamp too old'); + } + const payload = `${tsStr}.${rawBody.toString('utf8')}`; + const expected = crypto.createHmac('sha256', STRIPE_WEBHOOK_SECRET).update(payload).digest('hex'); + if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1Sig))) { + throw new Error('Signature mismatch'); + } + event = JSON.parse(rawBody.toString('utf8')); + } catch (e) { + console.error('[Stripe] Webhook verification failed:', e.message); + return res.status(400).json({ error: 'Webhook verification failed' }); + } + + // checkout.session.completed イベントのみ処理 + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + const email = session.customer_details?.email?.toLowerCase(); + if (email) { + try { + // ユーザーが存在すれば purchased_at を記録、なければ作成してから記録 + const existing = await pool.query( + `SELECT user_id FROM users WHERE email = $1`, [email] + ); + let userId; + if (existing.rows.length > 0) { + userId = existing.rows[0].user_id; + } else { + const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); + userId = `${baseId}_${Date.now().toString(36)}`; + await pool.query( + `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) + ON CONFLICT (user_id) DO NOTHING`, + [userId, baseId, email] + ); + } + await pool.query( + `UPDATE users SET purchased_at = NOW(), stripe_session_id = $1 + WHERE user_id = $2`, + [session.id, userId] + ); + console.log(`[Stripe] Purchase recorded for ${email} (userId: ${userId})`); + } catch (e) { + console.error('[Stripe] DB error recording purchase:', e.message); + return res.status(500).json({ error: 'DB error' }); + } + } + } + + res.json({ received: true }); +} + +// rawBody を保持するため独自パーサーを使用 +app.post('/brain/api/stripe/webhook', + express.raw({ type: 'application/json' }), + (req, res) => handleStripeWebhook(req, res) +); +app.post('/api/stripe/webhook', + express.raw({ type: 'application/json' }), + (req, res) => handleStripeWebhook(req, res) +); + // ── マウント(Tailscale経由と直接アクセスの両方対応) const router = buildRouter(); app.use('/brain/api', router); // Tailscale Funnel 経由