Compare commits
4 Commits
ada6eba333
...
5538cde753
| Author | SHA1 | Date |
|---|---|---|
|
|
5538cde753 | |
|
|
ee7b3053e2 | |
|
|
04b40a5b67 | |
|
|
2cd7795202 |
|
|
@ -5,10 +5,21 @@
|
|||
<meta name="robots" content="noindex, nofollow">
|
||||
<script>
|
||||
(function () {
|
||||
// Theme
|
||||
var t = localStorage.getItem('APP_ID-theme') || 'system';
|
||||
var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme-pref', t);
|
||||
|
||||
// JWT token handoff (cross-domain login from posimai-dashboard)
|
||||
var p = new URLSearchParams(location.search);
|
||||
var tk = p.get('token');
|
||||
if (tk) {
|
||||
localStorage.setItem('posimai_token', tk);
|
||||
p.delete('token');
|
||||
var u = location.pathname + (p.toString() ? '?' + p.toString() : '') + location.hash;
|
||||
history.replaceState({}, '', u);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ const STATIC = ['/', '/index.html', '/manifest.json', '/logo.png'];
|
|||
self.addEventListener('install', e => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then(c => c.addAll(STATIC))
|
||||
// skipWaiting() は意図的に呼ばない
|
||||
// → updatefound イベントで UI 側からユーザーに通知する方式を採用
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', e => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ echo ""
|
|||
|
||||
# --- Step 1: テンプレートコピー & 置換 ---
|
||||
echo "Step 1: テンプレートをコピーして置換..."
|
||||
cp -r "$SCRIPT_DIR/_template" "$TARGET_DIR"
|
||||
cp -r "$SCRIPT_DIR/_template-minimal" "$TARGET_DIR"
|
||||
|
||||
# sed で 3 変数を一括置換(macOS / Linux / Git Bash 対応)
|
||||
find "$TARGET_DIR" -type f \( -name "*.html" -o -name "*.json" -o -name "*.js" -o -name "*.md" \) | while IFS= read -r f; do
|
||||
|
|
@ -107,6 +107,86 @@ echo "Step 7: 初回本番デプロイをトリガー..."
|
|||
git commit --allow-empty -m "ci: trigger initial Vercel deployment"
|
||||
npm run deploy
|
||||
|
||||
# --- Step 8: Dashboard 自動更新 ---
|
||||
echo ""
|
||||
echo "Step 8: Dashboard を自動更新..."
|
||||
|
||||
DASHBOARD_DIR="$SCRIPT_DIR/posimai-dashboard"
|
||||
ROADMAP_DIR="$SCRIPT_DIR/posimai-roadmap"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# projects.json に追加
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const f = '$DASHBOARD_DIR/src/data/projects.json';
|
||||
const d = JSON.parse(fs.readFileSync(f, 'utf8'));
|
||||
const already = d.projects.some(p => p.id === '$APP_ID');
|
||||
if (!already) {
|
||||
d.projects.unshift({
|
||||
id: '$APP_ID',
|
||||
name: '$APP_NAME',
|
||||
category: 'micro',
|
||||
status: 'beta',
|
||||
description: '$APP_DESC',
|
||||
techStack: ['HTML', 'CSS', 'JavaScript', 'PWA'],
|
||||
links: { github: 'https://github.com/$GITHUB_ORG/$APP_ID' },
|
||||
pwa: true,
|
||||
themeColor: '#0D0D0D',
|
||||
accentColor: '#6EE7B7'
|
||||
});
|
||||
d.lastUpdated = '$TODAY';
|
||||
fs.writeFileSync(f, JSON.stringify(d, null, 4));
|
||||
console.log(' projects.json: 追加完了');
|
||||
} else {
|
||||
console.log(' projects.json: 既存エントリあり、スキップ');
|
||||
}
|
||||
" || echo " [WARN] projects.json の更新に失敗しました"
|
||||
|
||||
# timeline/page.tsx に launch エントリを追加
|
||||
TIMELINE_FILE="$DASHBOARD_DIR/src/app/timeline/page.tsx"
|
||||
if grep -q "\"$APP_ID\"" "$TIMELINE_FILE" 2>/dev/null; then
|
||||
echo " timeline/page.tsx: 既存エントリあり、スキップ"
|
||||
else
|
||||
# "const EVENTS: TimelineEvent[] = [" の直後に挿入
|
||||
sed -i "s/const EVENTS: TimelineEvent\[\] = \[/const EVENTS: TimelineEvent[] = [\n { date: \"$TODAY\", type: \"launch\", app: \"$APP_ID\", title: \"$APP_NAME リリース\", desc: \"$APP_DESC\" },/" \
|
||||
"$TIMELINE_FILE" \
|
||||
&& echo " timeline/page.tsx: 追加完了" \
|
||||
|| echo " [WARN] timeline/page.tsx の更新に失敗しました"
|
||||
fi
|
||||
|
||||
# roadmap.json に追加
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const f = '$ROADMAP_DIR/roadmap.json';
|
||||
const d = JSON.parse(fs.readFileSync(f, 'utf8'));
|
||||
if (!d.apps) d.apps = [];
|
||||
const already = d.apps.some(a => a.id === '$APP_ID');
|
||||
if (!already) {
|
||||
d.apps.push({ id: '$APP_ID', tasks: [] });
|
||||
fs.writeFileSync(f, JSON.stringify(d, null, 2));
|
||||
console.log(' roadmap.json: 追加完了');
|
||||
} else {
|
||||
console.log(' roadmap.json: 既存エントリあり、スキップ');
|
||||
}
|
||||
" || echo " [WARN] roadmap.json の更新に失敗しました"
|
||||
|
||||
# Dashboard と Roadmap をデプロイ
|
||||
echo ""
|
||||
echo " Dashboard をデプロイ中..."
|
||||
cd "$DASHBOARD_DIR"
|
||||
git add src/data/projects.json src/app/timeline/page.tsx && \
|
||||
git commit -m "feat: $APP_NAME をダッシュボードに追加" && \
|
||||
npm run deploy && \
|
||||
echo " Dashboard デプロイ完了" || echo " [WARN] Dashboard デプロイに失敗しました"
|
||||
|
||||
echo ""
|
||||
echo " Roadmap をデプロイ中..."
|
||||
cd "$ROADMAP_DIR"
|
||||
git add roadmap.json && \
|
||||
git commit -m "feat: $APP_NAME を roadmap に追加" && \
|
||||
npm run deploy && \
|
||||
echo " Roadmap デプロイ完了" || echo " [WARN] Roadmap デプロイに失敗しました"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 完了! $APP_NAME"
|
||||
|
|
@ -116,10 +196,14 @@ echo " ディレクトリ : $TARGET_DIR"
|
|||
echo " Gitea : http://100.76.7.3:3000/mai/$APP_ID"
|
||||
echo " GitHub : https://github.com/$GITHUB_ORG/$APP_ID"
|
||||
echo ""
|
||||
echo " 次の必須作業(Dashboard 更新):"
|
||||
echo " 1. posimai-dashboard/src/data/projects.json にカードを追加"
|
||||
echo " 2. ecosystem/page.tsx の NODES / EDGES に追加"
|
||||
echo " 3. timeline/page.tsx の EVENTS に追加"
|
||||
echo " 4. access/page.tsx の APPS に追加"
|
||||
echo " 5. Dashboard をデプロイ: cd posimai-dashboard && npm run deploy"
|
||||
echo " 自動完了済み:"
|
||||
echo " - projects.json にカード追加"
|
||||
echo " - timeline/page.tsx に launch エントリ追加"
|
||||
echo " - roadmap.json に追加"
|
||||
echo " - Dashboard / Roadmap デプロイ"
|
||||
echo ""
|
||||
echo " 手動で追加が必要なもの:"
|
||||
echo " 1. apps/page.tsx の projectIds に追加(カテゴリ判断が必要)"
|
||||
echo " 2. ecosystem/page.tsx の NODES / EDGES に追加(接続関係の判断が必要)"
|
||||
echo " 3. logo.png を配置後: cd $TARGET_DIR && git add logo.png && git commit -m 'feat: logo 追加' && npm run deploy"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ echo "→ Step 1: server.js を VPS に転送..."
|
|||
scp -i "$VPS_KEY" -o BatchMode=yes "$SERVER_FILE" "$VPS_HOST:$APP_DIR/server.js"
|
||||
echo " 転送完了"
|
||||
|
||||
echo ""
|
||||
echo "→ Step 1b: routes/ を VPS に転送..."
|
||||
ROUTES_DIR="$SCRIPT_DIR/routes"
|
||||
if [ -d "$ROUTES_DIR" ]; then
|
||||
ssh -i "$VPS_KEY" -o BatchMode=yes "$VPS_HOST" "mkdir -p $APP_DIR/routes"
|
||||
scp -i "$VPS_KEY" -o BatchMode=yes -r "$ROUTES_DIR"/* "$VPS_HOST:$APP_DIR/routes/"
|
||||
echo " routes/ 転送完了"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "→ Step 2: コンテナ再起動..."
|
||||
ssh -i "$VPS_KEY" -o BatchMode=yes "$VPS_HOST" "
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
'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,
|
||||
});
|
||||
}
|
||||
|
||||
// 初回アクティベート — WHERE device_id IS NULL で競合を防ぐ
|
||||
if (!license.device_id) {
|
||||
const activateResult = await pool.query(
|
||||
`UPDATE ponshu_licenses SET device_id = $1, activated_at = NOW()
|
||||
WHERE license_key = $2 AND device_id IS NULL
|
||||
RETURNING license_key`,
|
||||
[device_id, license_key]
|
||||
);
|
||||
if (activateResult.rowCount === 0) {
|
||||
// 別リクエストが先にアクティベートした → 再取得して照合
|
||||
const retry = await pool.query(
|
||||
`SELECT device_id FROM ponshu_licenses WHERE license_key = $1`, [license_key]
|
||||
);
|
||||
const current = retry.rows[0];
|
||||
if (!current || current.device_id !== device_id) {
|
||||
return res.json({
|
||||
valid: false,
|
||||
error: '別のデバイスで登録済みです。端末変更の場合はサポートまでご連絡ください。',
|
||||
supportEmail: APP_SUPPORT_EMAIL,
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
|
@ -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
107
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 経由
|
||||
|
|
|
|||
Loading…
Reference in New Issue