From 2cd7795202bb885b12b56b250410a9cf397c54fe Mon Sep 17 00:00:00 2001 From: posimai Date: Sat, 11 Apr 2026 00:16:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Ponshu=20Room=20Pro=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=82=BB=E3=83=B3=E3=82=B9=E7=AE=A1=E7=90=86=E3=82=92server.js?= =?UTF-8?q?=E3=81=B8=E7=B5=B1=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routes/ponshu.js: ライセンス検証・失効エンドポイントを新規追加 POST /api/ponshu/license/validate (認証不要、モバイルから直接呼ぶ) POST /api/ponshu/admin/license/revoke (APIキー認証必須) - routes/stripe.js: 既存のStripe Webhookハンドラーを抽出し拡張 metadata.product === 'ponshu_room_pro' の場合にライセンスキーを発行 Stripe Webhook 冪等性チェック (stripe_session_id) を追加 Resend でライセンスキーをメール送信 - server.js: ponshu_licenses テーブルをスキーマに追加 インラインのhandleStripeWebhook関数を routes/stripe.js に置き換え ponshuRouterとstripeRouterをマウント Co-Authored-By: Claude Sonnet 4.6 --- routes/ponshu.js | 101 +++++++++++++++++++++++++ routes/stripe.js | 189 +++++++++++++++++++++++++++++++++++++++++++++++ server.js | 107 +++++---------------------- 3 files changed, 308 insertions(+), 89 deletions(-) create mode 100644 routes/ponshu.js create mode 100644 routes/stripe.js diff --git a/routes/ponshu.js b/routes/ponshu.js new file mode 100644 index 00000000..b83a2ed3 --- /dev/null +++ b/routes/ponshu.js @@ -0,0 +1,101 @@ +'use strict'; +const express = require('express'); + +/** + * Ponshu Room ライセンス管理ルーター + * + * エンドポイント: + * POST /ponshu/license/validate (認証不要 — モバイルアプリから直接呼ぶ) + * POST /ponshu/admin/license/revoke (認証必須) + */ +module.exports = function createPonshuRouter(pool, authMiddleware) { + const router = express.Router(); + const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; + + // POST /ponshu/license/validate — 認証不要 + router.post('/ponshu/license/validate', async (req, res) => { + const { license_key, device_id } = req.body; + + if (!license_key || !device_id) { + return res.status(400).json({ valid: false, error: 'Missing parameters' }); + } + + try { + const result = await pool.query( + `SELECT license_key, plan, status, device_id FROM ponshu_licenses WHERE license_key = $1`, + [license_key] + ); + + if (result.rows.length === 0) { + return res.json({ valid: false, error: 'ライセンスキーが見つかりません' }); + } + + const license = result.rows[0]; + + if (license.status === 'revoked') { + return res.json({ + valid: false, + error: 'このライセンスは無効化されています。サポートにお問い合わせください。', + supportEmail: APP_SUPPORT_EMAIL, + }); + } + + // 初回アクティベート + if (!license.device_id) { + await pool.query( + `UPDATE ponshu_licenses SET device_id = $1, activated_at = NOW() WHERE license_key = $2`, + [device_id, license_key] + ); + console.log(`[Ponshu] License activated: ${license_key.substring(0, 12)}... -> Device: ${device_id.substring(0, 8)}...`); + return res.json({ valid: true, plan: license.plan, activated: true }); + } + + // 既存デバイスの照合 + if (license.device_id !== device_id) { + console.log(`[Ponshu] Device mismatch for license: ${license_key.substring(0, 12)}...`); + return res.json({ + valid: false, + error: '別のデバイスで登録済みです。端末変更の場合はサポートまでご連絡ください。', + supportEmail: APP_SUPPORT_EMAIL, + }); + } + + return res.json({ valid: true, plan: license.plan }); + + } catch (err) { + console.error('[Ponshu] License validate error:', err.message); + return res.status(500).json({ valid: false, error: 'サーバーエラーが発生しました' }); + } + }); + + // POST /ponshu/admin/license/revoke — 認証必須 + router.post('/ponshu/admin/license/revoke', authMiddleware, async (req, res) => { + const { license_key } = req.body; + + if (!license_key) { + return res.status(400).json({ success: false, error: 'license_key required' }); + } + + try { + const result = await pool.query( + `UPDATE ponshu_licenses SET status = 'revoked', revoked_at = NOW() + WHERE license_key = $1 AND status != 'revoked' + RETURNING license_key`, + [license_key] + ); + + if (result.rowCount === 0) { + return res.json({ success: false, error: 'License not found or already revoked' }); + } + + console.log(`[Ponshu] License revoked: ${license_key.substring(0, 12)}...`); + return res.json({ success: true, message: `License ${license_key} has been revoked` }); + + } catch (err) { + console.error('[Ponshu] Revoke error:', err.message); + return res.status(500).json({ success: false, error: 'Server error' }); + } + }); + + return router; +}; diff --git a/routes/stripe.js b/routes/stripe.js new file mode 100644 index 00000000..9a634c88 --- /dev/null +++ b/routes/stripe.js @@ -0,0 +1,189 @@ +'use strict'; +const crypto = require('crypto'); + +/** + * Stripe Webhook ハンドラー + * + * 処理するイベント: + * checkout.session.completed + * - metadata.product === 'ponshu_room_pro' + * -> ponshu_licenses にライセンスキーを発行し Resend でメール送信 + * - それ以外 + * -> users テーブルに購入記録(既存の Posimai 購読フロー) + * customer.subscription.deleted + * -> users.plan を free に戻す + * + * 使用方法: + * const { handleWebhook } = require('./routes/stripe')(pool); + * app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), + * (req, res) => handleWebhook(req, res)); + */ +module.exports = function createStripeHandler(pool) { + const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; + const RESEND_API_KEY = process.env.RESEND_API_KEY || ''; + const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; + const FROM_EMAIL = process.env.FROM_EMAIL || 'noreply@posimai.soar-enrich.com'; + + // ── ライセンスキー生成 (PONSHU-XXXX-XXXX-XXXX) ── + function generateLicenseKey() { + const hex = crypto.randomBytes(6).toString('hex').toUpperCase(); + return `PONSHU-${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}`; + } + + // ── Resend でライセンスキーをメール送信 ── + async function sendLicenseEmail(toEmail, licenseKey) { + if (!RESEND_API_KEY) { + console.warn('[Stripe] RESEND_API_KEY not set — skipping license email'); + return; + } + const emailRes = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${RESEND_API_KEY}`, + }, + body: JSON.stringify({ + from: FROM_EMAIL, + to: toEmail, + subject: 'Ponshu Room Pro ライセンスキーのお知らせ', + html: ` +

この度は Ponshu Room Pro をご購入いただきありがとうございます。

+

以下のライセンスキーをアプリ内「設定 → ライセンスを有効化」から入力してください。

+

+ ${licenseKey} +

+

ご不明な点は ${APP_SUPPORT_EMAIL} までお問い合わせください。

+ `, + }), + }); + if (!emailRes.ok) { + const errBody = await emailRes.text(); + console.error('[Stripe] Resend API error:', emailRes.status, errBody); + } else { + console.log(`[Stripe] License email sent to ${toEmail}`); + } + } + + // ── Ponshu Room Pro 購入処理 ── + async function handlePonshuPurchase(session) { + const email = session.customer_details?.email?.toLowerCase(); + if (!email) { + console.error('[Stripe] Ponshu purchase: no email in session', session.id); + return; + } + + // 冪等性チェック — Stripe がリトライしても重複発行しない + const existing = await pool.query( + `SELECT license_key FROM ponshu_licenses WHERE stripe_session_id = $1`, + [session.id] + ); + if (existing.rows.length > 0) { + console.log(`[Stripe] Ponshu: duplicate webhook for session ${session.id}, skipping`); + return; + } + + const licenseKey = generateLicenseKey(); + await pool.query( + `INSERT INTO ponshu_licenses (license_key, email, plan, status, stripe_session_id) + VALUES ($1, $2, 'pro', 'active', $3)`, + [licenseKey, email, session.id] + ); + + console.log(`[Stripe] Ponshu license issued: ${licenseKey.substring(0, 12)}... -> ${email}`); + await sendLicenseEmail(email, licenseKey); + } + + // ── Posimai 購読購入処理(既存フロー) ── + async function handlePosiPurchase(session) { + const email = session.customer_details?.email?.toLowerCase(); + if (!email) return; + + 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, + plan = 'premium', stripe_customer_id = $2, stripe_subscription_id = $3 + WHERE user_id = $4`, + [session.id, session.customer, session.subscription, userId] + ); + console.log(`[Stripe] Plan upgraded to premium for ${email}`); + } + + // ── Webhook エントリーポイント ── + async function handleWebhook(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 rawBody = req.body; // Buffer (express.raw が渡す) + const match = sig.match(/t=(\d+).*?,.*?v1=([a-fA-F0-9]+)/); + if (!match || match.length < 3) throw new Error('Invalid signature format'); + const [, tsStr, v1Sig] = match; + 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' }); + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + try { + if (session.metadata?.product === 'ponshu_room_pro') { + await handlePonshuPurchase(session); + } else { + await handlePosiPurchase(session); + } + } catch (e) { + console.error('[Stripe] Error processing checkout session:', e.message); + return res.status(500).json({ error: 'Processing error' }); + } + } + + if (event.type === 'customer.subscription.deleted') { + const subscription = event.data.object; + try { + await pool.query( + `UPDATE users SET plan = 'free', stripe_subscription_id = NULL + WHERE stripe_customer_id = $1`, + [subscription.customer] + ); + console.log(`[Stripe] Plan downgraded to free for customer: ${subscription.customer}`); + } catch (e) { + console.error('[Stripe] DB error downgrading plan:', e.message); + } + } + + res.json({ received: true }); + } + + return { handleWebhook }; +}; diff --git a/server.js b/server.js index ee704b46..834489cb 100644 --- a/server.js +++ b/server.js @@ -678,6 +678,17 @@ async function initDB() { user_id VARCHAR(50) PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, user_handle TEXT UNIQUE NOT NULL )`, + `CREATE TABLE IF NOT EXISTS ponshu_licenses ( + license_key TEXT PRIMARY KEY, + email TEXT NOT NULL, + plan VARCHAR(20) NOT NULL DEFAULT 'pro', + status VARCHAR(20) NOT NULL DEFAULT 'active', + device_id TEXT, + activated_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + stripe_session_id TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`, ]; for (const sql of schema) { await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message)); @@ -2889,96 +2900,9 @@ 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 ──────────────────────────────────────────────────── +// ── Stripe Webhook (routes/stripe.js) ──────────────────────────────── // 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 match = sig.match(/t=(\d+).*?,.*?v1=([a-fA-F0-9]+)/); - if (!match || match.length < 3) throw new Error('Invalid signature format'); - const [, tsStr, v1Sig] = match; - 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, - plan = 'premium', stripe_customer_id = $2, stripe_subscription_id = $3 - WHERE user_id = $4`, - [session.id, session.customer, session.subscription, userId] - ); - console.log(`[Stripe] Plan upgraded to premium for ${email}`); - } catch (e) { - console.error('[Stripe] DB error recording purchase:', e.message); - return res.status(500).json({ error: 'DB error' }); - } - } - } - - // サブスクリプション解約 → plan を free に戻す - if (event.type === 'customer.subscription.deleted') { - const subscription = event.data.object; - try { - await pool.query( - `UPDATE users SET plan = 'free', stripe_subscription_id = NULL - WHERE stripe_customer_id = $1`, - [subscription.customer] - ); - console.log(`[Stripe] Plan downgraded to free for customer: ${subscription.customer}`); - } catch (e) { - console.error('[Stripe] DB error downgrading plan:', e.message); - } - } - - res.json({ received: true }); -} - -// rawBody を保持するため独自パーサーを使用 +const { handleWebhook: handleStripeWebhook } = require('./routes/stripe')(pool); app.post('/brain/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => handleStripeWebhook(req, res) @@ -2988,6 +2912,11 @@ app.post('/api/stripe/webhook', (req, res) => handleStripeWebhook(req, res) ); +// ── Ponshu Room ライセンス (routes/ponshu.js) ───────────────────────── +const ponshuRouter = require('./routes/ponshu')(pool, authMiddleware); +app.use('/brain/api', ponshuRouter); +app.use('/api', ponshuRouter); + // ── マウント(Tailscale経由と直接アクセスの両方対応) const router = buildRouter(); app.use('/brain/api', router); // Tailscale Funnel 経由