mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
- Device WebSocket authentication: devices get a device_token on registration, must present it on reconnect. All WS events require prior auth. Timing-safe token comparison. - IDOR fixes: ownership checks on schedules (device, week), layouts (all CRUD, zones, duplicate, device assign), video-walls (content, device-config). - XSS prevention: shared esc() helper in utils.js, fixed 13 innerHTML injection points across 9 frontend files. - OAuth hardening: no longer silently overwrites auth_provider on accounts with local passwords (returns 409). - JWT pinned to HS256 for sign and verify. - Password policy: change endpoint now requires 8 chars (was 6). - HSTS header enabled (max-age 1 year, includeSubDomains). - Stripe webhook rejects unsigned payloads when no secret configured. - Screenshot size validation (max 2MB base64). - Rate limiting on exports, imports, content operations. - Content file serving checks playlist_items instead of old assignments. - Content ownership verified in device-groups assign-content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
6.8 KiB
JavaScript
174 lines
6.8 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { db } = require('../db/database');
|
|
const { requireAuth } = require('../middleware/auth');
|
|
const config = require('../config');
|
|
|
|
const appUrl = process.env.APP_URL || '';
|
|
|
|
let stripe = null;
|
|
if (config.stripeSecretKey) {
|
|
stripe = require('stripe')(config.stripeSecretKey);
|
|
}
|
|
|
|
// Create checkout session - user clicks "Upgrade" on a plan
|
|
router.post('/checkout', requireAuth, async (req, res) => {
|
|
if (!stripe) return res.status(503).json({ error: 'Stripe not configured' });
|
|
|
|
const { plan_id, interval } = req.body; // interval: 'monthly' or 'yearly'
|
|
if (!plan_id) return res.status(400).json({ error: 'plan_id required' });
|
|
|
|
const plan = db.prepare('SELECT * FROM plans WHERE id = ?').get(plan_id);
|
|
if (!plan) return res.status(404).json({ error: 'Plan not found' });
|
|
|
|
const priceId = interval === 'yearly' ? plan.stripe_price_yearly : plan.stripe_price_monthly;
|
|
if (!priceId) return res.status(400).json({ error: `No Stripe price configured for ${plan_id} (${interval || 'monthly'})` });
|
|
|
|
try {
|
|
// Get or create Stripe customer
|
|
let customerId = req.user.stripe_customer_id;
|
|
if (!customerId) {
|
|
const customer = await stripe.customers.create({
|
|
email: req.user.email,
|
|
metadata: { user_id: req.user.id, name: req.user.name || '' },
|
|
});
|
|
customerId = customer.id;
|
|
db.prepare('UPDATE users SET stripe_customer_id = ? WHERE id = ?').run(customerId, req.user.id);
|
|
}
|
|
|
|
// If user already has an active subscription, create a portal session to manage it
|
|
if (req.user.stripe_subscription_id) {
|
|
const portal = await stripe.billingPortal.sessions.create({
|
|
customer: customerId,
|
|
return_url: `${req.headers.origin || appUrl}/#/settings`,
|
|
});
|
|
return res.json({ url: portal.url, type: 'portal' });
|
|
}
|
|
|
|
// Create checkout session for new subscription
|
|
const session = await stripe.checkout.sessions.create({
|
|
customer: customerId,
|
|
mode: 'subscription',
|
|
payment_method_types: ['card'],
|
|
line_items: [{ price: priceId, quantity: 1 }],
|
|
success_url: `${req.headers.origin || appUrl}/#/settings?payment=success`,
|
|
cancel_url: `${req.headers.origin || appUrl}/#/settings?payment=cancelled`,
|
|
metadata: { user_id: req.user.id, plan_id },
|
|
subscription_data: {
|
|
metadata: { user_id: req.user.id, plan_id },
|
|
},
|
|
});
|
|
|
|
res.json({ url: session.url, type: 'checkout' });
|
|
} catch (err) {
|
|
console.error('Stripe checkout error:', err.message);
|
|
res.status(500).json({ error: 'Failed to create checkout session' });
|
|
}
|
|
});
|
|
|
|
// Customer portal - manage existing subscription (change plan, cancel, update payment)
|
|
router.post('/portal', requireAuth, async (req, res) => {
|
|
if (!stripe) return res.status(503).json({ error: 'Stripe not configured' });
|
|
|
|
const customerId = req.user.stripe_customer_id;
|
|
if (!customerId) return res.status(400).json({ error: 'No billing account found' });
|
|
|
|
try {
|
|
const session = await stripe.billingPortal.sessions.create({
|
|
customer: customerId,
|
|
return_url: `${req.headers.origin || appUrl}/#/settings`,
|
|
});
|
|
res.json({ url: session.url });
|
|
} catch (err) {
|
|
console.error('Stripe portal error:', err.message);
|
|
res.status(500).json({ error: 'Failed to create portal session' });
|
|
}
|
|
});
|
|
|
|
// Stripe webhook - handles all subscription lifecycle events
|
|
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
if (!stripe) return res.status(404).json({ error: 'Stripe not configured' });
|
|
|
|
let event;
|
|
try {
|
|
if (!config.stripeWebhookSecret) {
|
|
console.error('Stripe webhook secret not configured — rejecting unsigned webhook');
|
|
return res.status(400).json({ error: 'Webhook secret not configured' });
|
|
}
|
|
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
|
|
} catch (err) {
|
|
console.error('Webhook signature verification failed:', err.message);
|
|
return res.status(400).json({ error: 'Invalid signature' });
|
|
}
|
|
|
|
console.log(`Stripe webhook: ${event.type}`);
|
|
|
|
try {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const session = event.data.object;
|
|
const userId = session.metadata?.user_id;
|
|
const planId = session.metadata?.plan_id;
|
|
if (userId && session.subscription) {
|
|
db.prepare(`UPDATE users SET stripe_subscription_id = ?, plan_id = ?, subscription_status = 'active', updated_at = strftime('%s','now') WHERE id = ?`)
|
|
.run(session.subscription, planId || 'starter', userId);
|
|
console.log(`User ${userId} subscribed to ${planId} (sub: ${session.subscription})`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'customer.subscription.updated': {
|
|
const sub = event.data.object;
|
|
const userId = sub.metadata?.user_id;
|
|
if (!userId) break;
|
|
|
|
// Find plan by stripe price ID
|
|
const priceId = sub.items?.data?.[0]?.price?.id;
|
|
let planId = sub.metadata?.plan_id;
|
|
if (priceId && !planId) {
|
|
const plan = db.prepare('SELECT id FROM plans WHERE stripe_price_monthly = ? OR stripe_price_yearly = ?').get(priceId, priceId);
|
|
if (plan) planId = plan.id;
|
|
}
|
|
|
|
const status = sub.status === 'active' ? 'active' : sub.status === 'past_due' ? 'past_due' : sub.status;
|
|
const ends = sub.current_period_end || null;
|
|
|
|
db.prepare(`UPDATE users SET plan_id = COALESCE(?, plan_id), subscription_status = ?, subscription_ends = ?, updated_at = strftime('%s','now') WHERE id = ?`)
|
|
.run(planId, status, ends, userId);
|
|
console.log(`Subscription updated for ${userId}: ${planId} (${status})`);
|
|
break;
|
|
}
|
|
|
|
case 'customer.subscription.deleted': {
|
|
const sub = event.data.object;
|
|
const userId = sub.metadata?.user_id;
|
|
if (userId) {
|
|
db.prepare(`UPDATE users SET plan_id = 'free', subscription_status = 'cancelled', stripe_subscription_id = NULL, updated_at = strftime('%s','now') WHERE id = ?`)
|
|
.run(userId);
|
|
console.log(`Subscription cancelled for ${userId}`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'invoice.payment_failed': {
|
|
const invoice = event.data.object;
|
|
const subId = invoice.subscription;
|
|
if (subId) {
|
|
const user = db.prepare('SELECT id FROM users WHERE stripe_subscription_id = ?').get(subId);
|
|
if (user) {
|
|
db.prepare("UPDATE users SET subscription_status = 'past_due', updated_at = strftime('%s','now') WHERE id = ?").run(user.id);
|
|
console.log(`Payment failed for user ${user.id}`);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Webhook processing error:', err.message);
|
|
}
|
|
|
|
res.json({ received: true });
|
|
});
|
|
|
|
module.exports = router;
|