feat: Ponshu Room Proライセンス管理をserver.jsへ統合

- 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 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-11 00:16:57 +09:00
parent ada6eba333
commit 2cd7795202
3 changed files with 308 additions and 89 deletions

101
routes/ponshu.js Normal file
View File

@ -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;
};

189
routes/stripe.js Normal file
View File

@ -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: `
<p>この度は Ponshu Room Pro をご購入いただきありがとうございます</p>
<p>以下のライセンスキーをアプリ内設定 ライセンスを有効化から入力してください</p>
<p style="font-size:24px;font-weight:bold;letter-spacing:4px;font-family:monospace;
background:#f5f5f5;padding:16px;border-radius:8px;display:inline-block;">
${licenseKey}
</p>
<p>ご不明な点は <a href="mailto:${APP_SUPPORT_EMAIL}">${APP_SUPPORT_EMAIL}</a> </p>
`,
}),
});
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 };
};

107
server.js
View File

@ -678,6 +678,17 @@ async function initDB() {
user_id VARCHAR(50) PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, user_id VARCHAR(50) PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
user_handle TEXT UNIQUE NOT NULL 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) { for (const sql of schema) {
await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message)); 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('/brain/api/uploads', express.static(UPLOADS_DIR));
app.use('/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 より前に配置 // rawBody が必要なため express.json() より前・router より前に配置
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; const { handleWebhook: handleStripeWebhook } = require('./routes/stripe')(pool);
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 を保持するため独自パーサーを使用
app.post('/brain/api/stripe/webhook', app.post('/brain/api/stripe/webhook',
express.raw({ type: 'application/json' }), express.raw({ type: 'application/json' }),
(req, res) => handleStripeWebhook(req, res) (req, res) => handleStripeWebhook(req, res)
@ -2988,6 +2912,11 @@ app.post('/api/stripe/webhook',
(req, res) => handleStripeWebhook(req, res) (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経由と直接アクセスの両方対応 // ── マウントTailscale経由と直接アクセスの両方対応
const router = buildRouter(); const router = buildRouter();
app.use('/brain/api', router); // Tailscale Funnel 経由 app.use('/brain/api', router); // Tailscale Funnel 経由