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:
parent
c53abecbca
commit
0590d0995d
124
server.js
124
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];
|
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 経由
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue