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 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-05 02:22:18 +09:00
parent c53abecbca
commit 0590d0995d
1 changed files with 120 additions and 4 deletions

124
server.js
View File

@ -193,7 +193,7 @@ function authMiddleware(req, res, next) {
} }
} }
// API key (legacy) // API key (internal users — skip purchase check)
const userId = KEY_MAP[token]; const userId = KEY_MAP[token];
if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' }); if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' });
req.userId = userId; req.userId = userId;
@ -201,6 +201,27 @@ function authMiddleware(req, res, next) {
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 = { const SOURCE_MAP = {
'zenn.dev': 'Zenn', 'qiita.com': 'Qiita', 'zenn.dev': 'Zenn', 'qiita.com': 'Qiita',
@ -615,6 +636,8 @@ async function initDB() {
`ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)`, `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`, `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 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) { for (const sql of migrations) {
await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message)); 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 // GET /api/auth/session/verify — check current JWT + purchase status
r.get('/auth/session/verify', authMiddleware, (req, res) => { r.get('/auth/session/verify', authMiddleware, async (req, res) => {
res.json({ ok: true, userId: req.userId, authType: req.authType }); 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 ─────────────────────────────────────────── // ── 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('/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 ────────────────────────────────────────────────────
// 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経由と直接アクセスの両方対応 // ── マウントTailscale経由と直接アクセスの両方対応
const router = buildRouter(); const router = buildRouter();
app.use('/brain/api', router); // Tailscale Funnel 経由 app.use('/brain/api', router); // Tailscale Funnel 経由