Harden auth, payments, and frontend against review findings.
Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+268
-84
@@ -1,11 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
|
||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
|
||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||
|
||||
@@ -17,6 +18,9 @@ const attendeeSchema = z.object({
|
||||
lastName: z.string().min(2).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
// Maximum tickets a single buyer can book at once (enforced server-side)
|
||||
const MAX_TICKETS_PER_BOOKING = 5;
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
eventId: z.string(),
|
||||
firstName: z.string().min(2),
|
||||
@@ -24,12 +28,30 @@ const createTicketSchema = z.object({
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(6).optional().or(z.literal('')),
|
||||
preferredLanguage: z.enum(['en', 'es']).optional(),
|
||||
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
// 'bancard' intentionally excluded: no checkout integration exists for it
|
||||
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
|
||||
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
|
||||
// Optional: array of attendees for multi-ticket booking
|
||||
attendees: z.array(attendeeSchema).optional(),
|
||||
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
|
||||
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
|
||||
});
|
||||
|
||||
// Maps a payment provider to the merged payment-option flag that enables it
|
||||
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
|
||||
const truthy = (v: any) => v === true || v === 1;
|
||||
switch (method) {
|
||||
case 'tpago':
|
||||
return truthy(merged.tpagoEnabled);
|
||||
case 'bank_transfer':
|
||||
return truthy(merged.bankTransferEnabled);
|
||||
case 'lightning':
|
||||
return truthy(merged.lightningEnabled);
|
||||
case 'cash':
|
||||
return truthy(merged.cashEnabled);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const updateTicketSchema = z.object({
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
|
||||
adminNote: z.string().optional(),
|
||||
@@ -60,6 +82,11 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
: [{ firstName: data.firstName, lastName: data.lastName }];
|
||||
|
||||
const ticketCount = attendeesList.length;
|
||||
|
||||
// Enforce the per-booking ticket cap server-side (UI also caps, but the API is authoritative)
|
||||
if (ticketCount < 1 || ticketCount > MAX_TICKETS_PER_BOOKING) {
|
||||
return c.json({ error: `You can book between 1 and ${MAX_TICKETS_PER_BOOKING} tickets per order.` }, 400);
|
||||
}
|
||||
|
||||
// Get event
|
||||
const event = await dbGet<any>(
|
||||
@@ -72,9 +99,28 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
if (!['published', 'unlisted'].includes(event.status)) {
|
||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||
}
|
||||
|
||||
// Validate the requested payment method is actually enabled for this event
|
||||
// (merge global options with any event-level overrides; override wins when not null)
|
||||
const globalPaymentOptions = await dbGet<any>(
|
||||
(db as any).select().from(paymentOptions)
|
||||
);
|
||||
const eventOverrides = await dbGet<any>(
|
||||
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId))
|
||||
);
|
||||
const mergedPaymentOptions: Record<string, any> = {
|
||||
tpagoEnabled: eventOverrides?.tpagoEnabled ?? globalPaymentOptions?.tpagoEnabled ?? false,
|
||||
bankTransferEnabled: eventOverrides?.bankTransferEnabled ?? globalPaymentOptions?.bankTransferEnabled ?? false,
|
||||
lightningEnabled: eventOverrides?.lightningEnabled ?? globalPaymentOptions?.lightningEnabled ?? true,
|
||||
cashEnabled: eventOverrides?.cashEnabled ?? globalPaymentOptions?.cashEnabled ?? true,
|
||||
};
|
||||
if (!isPaymentMethodEnabled(data.paymentMethod, mergedPaymentOptions)) {
|
||||
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
|
||||
}
|
||||
|
||||
// Check capacity - count confirmed AND checked_in tickets
|
||||
// (checked_in were previously confirmed, check-in doesn't affect capacity)
|
||||
// Check capacity - count pending, confirmed AND checked_in tickets.
|
||||
// Pending reservations must hold seats to prevent overselling via unpaid bookings
|
||||
// (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats).
|
||||
const existingTicketCount = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -82,7 +128,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -128,13 +174,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
}
|
||||
|
||||
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
|
||||
const globalOptions = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(paymentOptions)
|
||||
);
|
||||
|
||||
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
|
||||
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
|
||||
|
||||
if (!allowDuplicateBookings) {
|
||||
const existingTicket = await dbGet<any>(
|
||||
@@ -156,52 +196,151 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
// Generate booking ID to group multiple tickets
|
||||
const bookingId = generateId();
|
||||
|
||||
// Create tickets for each attendee
|
||||
const createdTickets: any[] = [];
|
||||
const createdPayments: any[] = [];
|
||||
|
||||
for (let i = 0; i < attendeesList.length; i++) {
|
||||
const attendee = attendeesList[i];
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: attendee.firstName,
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email, // Buyer's email for all tickets
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(tickets).values(newTicket);
|
||||
createdTickets.push(newTicket);
|
||||
|
||||
// Create payment record for each ticket
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: data.paymentMethod,
|
||||
amount: event.price,
|
||||
currency: event.currency,
|
||||
status: 'pending',
|
||||
reference: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await (db as any).insert(payments).values(newPayment);
|
||||
createdPayments.push(newPayment);
|
||||
|
||||
// Atomically re-check capacity and insert tickets/payments inside a transaction
|
||||
// so concurrent bookings cannot oversell the same seats (TOCTOU race).
|
||||
class BookingCapacityError extends Error {
|
||||
constructor(public code: 'SOLD_OUT' | 'NOT_ENOUGH', public available?: number) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
let createdTickets: any[] = [];
|
||||
let createdPayments: any[] = [];
|
||||
|
||||
try {
|
||||
if (isSqlite()) {
|
||||
(db as any).transaction((tx: any) => {
|
||||
const countRow = tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
.get();
|
||||
const reserved = Number(countRow?.count || 0);
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new BookingCapacityError('SOLD_OUT');
|
||||
}
|
||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||
if (ticketCount > seatsLeft) {
|
||||
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
|
||||
}
|
||||
|
||||
for (let i = 0; i < attendeesList.length; i++) {
|
||||
const attendee = attendeesList[i];
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
bookingId: ticketCount > 1 ? bookingId : null,
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: attendee.firstName,
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
createdAt: now,
|
||||
};
|
||||
tx.insert(tickets).values(newTicket).run();
|
||||
createdTickets.push(newTicket);
|
||||
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: data.paymentMethod,
|
||||
amount: event.price,
|
||||
currency: event.currency,
|
||||
status: 'pending',
|
||||
reference: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
tx.insert(payments).values(newPayment).run();
|
||||
createdPayments.push(newPayment);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await (db as any).transaction(async (tx: any) => {
|
||||
const countRow = await dbGet<any>(
|
||||
tx
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tickets)
|
||||
.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, data.eventId),
|
||||
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
|
||||
)
|
||||
)
|
||||
);
|
||||
const reserved = Number(countRow?.count || 0);
|
||||
if (isEventSoldOut(event.capacity, reserved)) {
|
||||
throw new BookingCapacityError('SOLD_OUT');
|
||||
}
|
||||
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
|
||||
if (ticketCount > seatsLeft) {
|
||||
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
|
||||
}
|
||||
|
||||
for (let i = 0; i < attendeesList.length; i++) {
|
||||
const attendee = attendeesList[i];
|
||||
const ticketId = generateId();
|
||||
const qrCode = generateTicketCode();
|
||||
const newTicket = {
|
||||
id: ticketId,
|
||||
bookingId: ticketCount > 1 ? bookingId : null,
|
||||
userId: user.id,
|
||||
eventId: data.eventId,
|
||||
attendeeFirstName: attendee.firstName,
|
||||
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
|
||||
attendeeEmail: data.email,
|
||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||
attendeeRuc: data.ruc || null,
|
||||
preferredLanguage: data.preferredLanguage || null,
|
||||
status: 'pending',
|
||||
qrCode,
|
||||
checkinAt: null,
|
||||
createdAt: now,
|
||||
};
|
||||
await tx.insert(tickets).values(newTicket);
|
||||
createdTickets.push(newTicket);
|
||||
|
||||
const paymentId = generateId();
|
||||
const newPayment = {
|
||||
id: paymentId,
|
||||
ticketId,
|
||||
provider: data.paymentMethod,
|
||||
amount: event.price,
|
||||
currency: event.currency,
|
||||
status: 'pending',
|
||||
reference: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await tx.insert(payments).values(newPayment);
|
||||
createdPayments.push(newPayment);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err instanceof BookingCapacityError) {
|
||||
if (err.code === 'SOLD_OUT') {
|
||||
return c.json({ error: 'Event is sold out' }, 400);
|
||||
}
|
||||
return c.json({
|
||||
error: `Not enough seats available. Only ${err.available} spot(s) remaining.`,
|
||||
}, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const primaryTicket = createdTickets[0];
|
||||
@@ -221,11 +360,26 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
});
|
||||
}
|
||||
|
||||
// If Lightning payment, create LNbits invoice
|
||||
// If Lightning payment, create LNbits invoice (skip for free events — confirm immediately)
|
||||
let lnbitsInvoice = null;
|
||||
const totalPrice = event.price * ticketCount;
|
||||
|
||||
if (data.paymentMethod === 'lightning' && totalPrice > 0) {
|
||||
|
||||
// Free events: no payment step required — confirm tickets immediately
|
||||
if (totalPrice === 0) {
|
||||
for (const t of createdTickets) {
|
||||
await (db as any)
|
||||
.update(tickets)
|
||||
.set({ status: 'confirmed' })
|
||||
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({ status: 'paid', paidAt: now, updatedAt: now })
|
||||
.where(and(eq((payments as any).ticketId, t.id), eq((payments as any).status, 'pending')));
|
||||
}
|
||||
emailService.sendBookingConfirmation(primaryTicket.id).catch(err => {
|
||||
console.error('[Email] Failed to send free-booking confirmation:', err);
|
||||
});
|
||||
} else if (data.paymentMethod === 'lightning') {
|
||||
if (!isLNbitsConfigured()) {
|
||||
// Delete the tickets and payments we just created
|
||||
for (const payment of createdPayments) {
|
||||
@@ -241,6 +395,11 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
// Include the webhook secret (if configured) so the callback can be authenticated
|
||||
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
|
||||
const webhookUrl = webhookSecret
|
||||
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
|
||||
: `${apiUrl}/api/lnbits/webhook`;
|
||||
|
||||
// Pass the fiat currency directly to LNbits - it handles conversion automatically
|
||||
// For multi-ticket, use total price
|
||||
@@ -248,7 +407,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
||||
amount: totalPrice,
|
||||
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
|
||||
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
|
||||
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
|
||||
webhookUrl,
|
||||
expiry: 900, // 15 minutes expiry for faster UX
|
||||
extra: {
|
||||
ticketId: primaryTicket.id,
|
||||
@@ -610,6 +769,9 @@ ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async
|
||||
});
|
||||
|
||||
// Get ticket by ID
|
||||
// Capability-based access: the unguessable ticket UUID acts as the access token for
|
||||
// guest bookings (no account required). For anonymous callers we withhold attendee PII
|
||||
// (email/phone/RUC); the full record is only returned to the owner or admin/staff.
|
||||
ticketsRouter.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
@@ -639,15 +801,22 @@ ticketsRouter.get('/:id', async (c) => {
|
||||
);
|
||||
bookingTicketCount = bookingTickets.length || 1;
|
||||
}
|
||||
|
||||
// Determine whether the requester is the owner or an admin/staff member
|
||||
const authUser: any = await getAuthUser(c);
|
||||
const isPrivileged = !!authUser && (
|
||||
['admin', 'organizer', 'staff'].includes(authUser.role) || authUser.id === ticket.userId
|
||||
);
|
||||
|
||||
const ticketPayload: any = { ...ticket, event, payment, bookingTicketCount };
|
||||
if (!isPrivileged) {
|
||||
// Strip attendee PII for anonymous capability-based access
|
||||
delete ticketPayload.attendeeEmail;
|
||||
delete ticketPayload.attendeePhone;
|
||||
delete ticketPayload.attendeeRuc;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ticket: {
|
||||
...ticket,
|
||||
event,
|
||||
payment,
|
||||
bookingTicketCount,
|
||||
},
|
||||
});
|
||||
return c.json({ ticket: ticketPayload });
|
||||
});
|
||||
|
||||
// Update ticket status (admin/organizer)
|
||||
@@ -985,7 +1154,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
||||
|
||||
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
|
||||
// This sets status to "pending_approval" and notifies admin
|
||||
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, windowMs: 10 * 60 * 1000, prefix: 'mark-payment-sent' }), async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const { payerName } = body;
|
||||
@@ -1039,18 +1208,33 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
|
||||
|
||||
const now = getNow();
|
||||
|
||||
// Update payment status to pending_approval
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'pending_approval',
|
||||
userMarkedPaidAt: now,
|
||||
payerName: payerName?.trim() || null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq((payments as any).id, payment.id));
|
||||
// Update payment status to pending_approval for this ticket and any siblings
|
||||
// in a multi-ticket booking (mirrors the approve flow's bookingId fan-out).
|
||||
let ticketsToMark: any[] = [ticket];
|
||||
if (ticket.bookingId) {
|
||||
ticketsToMark = await dbAll<any>(
|
||||
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of ticketsToMark) {
|
||||
await (db as any)
|
||||
.update(payments)
|
||||
.set({
|
||||
status: 'pending_approval',
|
||||
userMarkedPaidAt: now,
|
||||
payerName: payerName?.trim() || null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq((payments as any).ticketId, (t as any).id),
|
||||
eq((payments as any).status, 'pending')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated payment
|
||||
// Get updated payment for the requested ticket
|
||||
const updatedPayment = await dbGet(
|
||||
(db as any)
|
||||
.select()
|
||||
|
||||
Reference in New Issue
Block a user