diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 5c4c187..70c388d 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -5,7 +5,7 @@ import { uniqueSlug } from '../lib/slugify.js'; const dbType = process.env.DB_TYPE || 'sqlite'; console.log(`Database type: ${dbType}`); -console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`); +// Do not log DATABASE_URL: it may contain credentials. async function migrate() { console.log('Running migrations...'); @@ -43,6 +43,9 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`); } catch (e) { /* column may already exist */ } + try { + await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } // Magic link tokens table await (db as any).run(sql` @@ -541,6 +544,9 @@ async function migrate() { try { await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`); } catch (e) { /* column may already exist */ } + try { + await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } // Magic link tokens table await (db as any).execute(sql` @@ -974,6 +980,27 @@ async function migrate() { `); } + // Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines) + const indexStatements = [ + `CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`, + `CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`, + `CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`, + `CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`, + `CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`, + `CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`, + `CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`, + `CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`, + ]; + for (const stmt of indexStatements) { + try { + if (dbType === 'sqlite') { + await (db as any).run(sql.raw(stmt)); + } else { + await (db as any).execute(sql.raw(stmt)); + } + } catch (e) { /* index may already exist */ } + } + // Backfill slugs for any events that don't have one yet (shared across DB types). // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index c8e834a..1efc7ca 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -18,6 +18,8 @@ export const sqliteUsers = sqliteTable('users', { googleId: text('google_id'), rucNumber: text('ruc_number'), accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'), + // Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset) + tokenVersion: integer('token_version').notNull().default(0), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), }); @@ -359,6 +361,8 @@ export const pgUsers = pgTable('users', { googleId: varchar('google_id', { length: 255 }), rucNumber: varchar('ruc_number', { length: 15 }), accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'), + // Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset) + tokenVersion: pgInteger('token_version').notNull().default(0), createdAt: timestamp('created_at').notNull(), updatedAt: timestamp('updated_at').notNull(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 495ef96..82d876d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -56,6 +56,19 @@ app.use( }) ); +// Baseline security headers on every response. +const isProduction = process.env.NODE_ENV === 'production'; +app.use('*', async (c, next) => { + await next(); + c.header('X-Content-Type-Options', 'nosniff'); + c.header('X-Frame-Options', 'DENY'); + c.header('Referrer-Policy', 'strict-origin-when-cross-origin'); + c.header('X-XSS-Protection', '0'); + if (isProduction) { + c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } +}); + // OpenAPI specification const openApiSpec = { openapi: '3.0.0', @@ -1827,15 +1840,34 @@ const openApiSpec = { }, }; -// OpenAPI JSON endpoint -app.get('/openapi.json', (c) => { - return c.json(openApiSpec); +// API documentation is disabled in production to avoid exposing the full API +// surface (and schema) to anonymous users. Enable locally / in non-prod only. +if (!isProduction) { + // OpenAPI JSON endpoint + app.get('/openapi.json', (c) => { + return c.json(openApiSpec); + }); + + // Swagger UI + app.get('/api-docs', swaggerUI({ url: '/openapi.json' })); +} else { + app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404)); + app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404)); +} + +// Static file serving for uploads. +// Uploads are validated as images at write time, but as defense-in-depth we force +// any non-image path to download as an opaque attachment so a stray/legacy +// .html/.svg can never be rendered (and therefore never execute script) in-origin. +app.use('/uploads/*', async (c, next) => { + await next(); + const path = c.req.path.toLowerCase(); + const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path); + if (!isInlineImage) { + c.header('Content-Disposition', 'attachment'); + c.header('Content-Type', 'application/octet-stream'); + } }); - -// Swagger UI -app.get('/api-docs', swaggerUI({ url: '/openapi.json' })); - -// Static file serving for uploads app.use('/uploads/*', serveStatic({ root: './' })); // Health check diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 26a42da..1c732a6 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -4,10 +4,21 @@ import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { Context } from 'hono'; import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js'; -import { eq, and, gt } from 'drizzle-orm'; +import { eq, and, gt, sql, isNull } from 'drizzle-orm'; import { generateId, getNow, toDbDate } from './utils.js'; -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production'); +const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production'; +const rawJwtSecret = process.env.JWT_SECRET; + +// Never allow the insecure default in production: forgeable tokens = full account takeover. +if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) { + throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.'); +} +if (!rawJwtSecret) { + console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.'); +} + +const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET); const JWT_ISSUER = 'spanglish'; const JWT_AUDIENCE = 'spanglish-app'; @@ -15,6 +26,7 @@ export interface JWTPayload { sub: string; email: string; role: string; + tokenVersion?: number; iat: number; exp: number; } @@ -84,23 +96,36 @@ export async function verifyMagicLinkToken( ) ); + // Use a single generic error for all invalid states to avoid leaking token state + const genericError = 'Invalid or expired token'; + if (!tokenRecord) { - return { valid: false, error: 'Invalid token' }; + return { valid: false, error: genericError }; } if (tokenRecord.usedAt) { - return { valid: false, error: 'Token already used' }; + return { valid: false, error: genericError }; } if (new Date(tokenRecord.expiresAt) < new Date()) { - return { valid: false, error: 'Token expired' }; + return { valid: false, error: genericError }; } - // Mark token as used - await (db as any) + // Atomically consume the token: only the request that flips used_at from NULL wins. + // This prevents a double-spend race where two concurrent requests both pass the + // read-time "not used" check above. + const result: any = await (db as any) .update(magicLinkTokens) .set({ usedAt: now }) - .where(eq((magicLinkTokens as any).id, tokenRecord.id)); + .where(and( + eq((magicLinkTokens as any).id, tokenRecord.id), + isNull((magicLinkTokens as any).usedAt) + )); + + const affected = result?.changes ?? result?.rowCount ?? 0; + if (affected === 0) { + return { valid: false, error: genericError }; + } return { valid: true, userId: tokenRecord.userId }; } @@ -175,18 +200,26 @@ export function validatePassword(password: string): { valid: boolean; error?: st return { valid: true }; } -export async function createToken(userId: string, email: string, role: string): Promise { - const token = await new jose.SignJWT({ sub: userId, email, role }) +export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise { + const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setIssuer(JWT_ISSUER) .setAudience(JWT_AUDIENCE) - .setExpirationTime('7d') + .setExpirationTime('1d') .sign(JWT_SECRET); return token; } +// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset). +export async function bumpTokenVersion(userId: string): Promise { + await (db as any) + .update(users) + .set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` }) + .where(eq((users as any).id, userId)); +} + export async function createRefreshToken(userId: string): Promise { const token = await new jose.SignJWT({ sub: userId, type: 'refresh' }) .setProtectedHeader({ alg: 'HS256' }) @@ -223,10 +256,44 @@ export async function getAuthUser(c: Context): Promise { return null; } + // Never load the password hash into request context — it is only needed for + // explicit password-verification routes that query it separately. const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).id, payload.sub)) + (db as any) + .select({ + id: (users as any).id, + email: (users as any).email, + name: (users as any).name, + phone: (users as any).phone, + role: (users as any).role, + languagePreference: (users as any).languagePreference, + isClaimed: (users as any).isClaimed, + googleId: (users as any).googleId, + rucNumber: (users as any).rucNumber, + accountStatus: (users as any).accountStatus, + tokenVersion: (users as any).tokenVersion, + createdAt: (users as any).createdAt, + updatedAt: (users as any).updatedAt, + }) + .from(users) + .where(eq((users as any).id, payload.sub)) ); - return user || null; + + if (!user) { + return null; + } + + // Reject tokens issued before a logout-everywhere / password change + if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) { + return null; + } + + // Suspended/unclaimed accounts must not retain API access via an old JWT + if (user.accountStatus && user.accountStatus !== 'active') { + return null; + } + + return user; } export function requireAuth(roles?: string[]) { @@ -252,3 +319,15 @@ export async function isFirstUser(): Promise { ); return !result || result.length === 0; } + +/** Fetch only the password hash column (never expose via getAuthUser). */ +export async function getUserPasswordHash(userId: string): Promise { + const row = await dbGet( + (db as any) + .select({ password: (users as any).password }) + .from(users) + .where(eq((users as any).id, userId)) + ); + const hash = row?.password; + return hash && String(hash).length > 0 ? String(hash) : null; +} diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 5ff1449..efb9ecc 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -247,10 +247,20 @@ async function sendWithConsole(options: SendEmailOptions): Promise { const provider = getEmailProvider(); - console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`); + const recipientCount = Array.isArray(options.to) ? options.to.length : 1; + const sample = Array.isArray(options.to) ? options.to[0] : options.to; + console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`); switch (provider) { case 'resend': @@ -478,7 +488,7 @@ export const emailService = { // Replace variables const finalSubject = replaceTemplateVariables(subject, allVariables); - const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables); + const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true); const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject }); const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined; @@ -1292,7 +1302,14 @@ export const emailService = { eventId?: string; sentBy?: string | null; }): Promise<{ success: boolean; logId?: string; error?: string }> { - const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params; + const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params; + + // Strip CR/LF from header-bound values to prevent email header injection + // (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients). + const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v); + const to = stripHeader(rawTo) as string; + const subject = stripHeader(rawSubject) as string; + const replyTo = stripHeader(rawReplyTo); const allVariables = { ...this.getCommonVariables(), diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts index 6340aba..876ffc5 100644 --- a/backend/src/lib/emailTemplates.ts +++ b/backend/src/lib/emailTemplates.ts @@ -1216,8 +1216,26 @@ Spanglish`, }, ]; -// Helper function to replace template variables -export function replaceTemplateVariables(template: string, variables: Record): string { +// Escape HTML-significant characters so substituted variable values can't inject markup. +function escapeHtmlValue(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Helper function to replace template variables. +// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are +// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event +// location, attendee name). The template markup itself is never escaped. Subjects and +// plain-text bodies pass `escapeHtml=false` so they don't show literal entities. +export function replaceTemplateVariables( + template: string, + variables: Record, + escapeHtml: boolean = false +): string { let result = template; // Handle conditional blocks {{#if variable}}...{{/if}} @@ -1229,16 +1247,20 @@ export function replaceTemplateVariables(template: string, variables: Record { - return variables[varName] !== undefined ? String(variables[varName]) : match; + if (variables[varName] === undefined) return match; + const raw = String(variables[varName]); + return escapeHtml ? escapeHtmlValue(raw) : raw; }); return result; } -// Helper to wrap content in the base template +// Helper to wrap content in the base template. +// `content` is treated as trusted HTML (it is the already-rendered body). The wrapper's +// own variables (subject, year, etc.) are HTML-escaped on substitution. export function wrapInBaseTemplate(content: string, variables: Record): string { - const wrappedContent = baseEmailWrapper.replace('{{content}}', content); - return replaceTemplateVariables(wrappedContent, variables); + const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content); + return replaceTemplateVariables(wrappedContent, variables, true); } // Get all available variables for a template by slug diff --git a/backend/src/lib/lnbits.ts b/backend/src/lib/lnbits.ts index 5bdb13c..66cc131 100644 --- a/backend/src/lib/lnbits.ts +++ b/backend/src/lib/lnbits.ts @@ -80,11 +80,8 @@ export async function createInvoice(params: CreateInvoiceParams): Promise(); + +// Periodically drop expired buckets so the Map does not grow unbounded. +const cleanup = setInterval(() => { + const now = Date.now(); + for (const [key, bucket] of buckets) { + if (now > bucket.resetAt) buckets.delete(key); + } +}, 60_000); +// Don't keep the process alive just for cleanup. +(cleanup as any).unref?.(); + +/** Best-effort client IP extraction (honours common reverse-proxy headers). */ +export function getClientIp(c: Context): string { + const forwarded = c.req.header('x-forwarded-for'); + if (forwarded) return forwarded.split(',')[0].trim(); + return c.req.header('x-real-ip') || 'unknown'; +} + +/** + * Consume one unit against a key. Returns whether the request is allowed and, + * when blocked, how many seconds until the window resets. + */ +export function consumeRateLimit( + key: string, + max: number, + windowMs: number +): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const bucket = buckets.get(key); + + if (!bucket || now > bucket.resetAt) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true }; + } + + bucket.count++; + if (bucket.count > max) { + return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) }; + } + return { allowed: true }; +} + +/** + * Hono middleware factory that rate-limits by client IP. + * Use a distinct `prefix` per endpoint group so unrelated routes don't share a bucket. + */ +export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) { + return async (c: Context, next: () => Promise) => { + const ip = getClientIp(c); + const result = consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs); + if (!result.allowed) { + return c.json( + { error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter }, + 429 + ); + } + await next(); + }; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 70feaeb..34cdab9 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -67,14 +67,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((payments as any).status, 'pending')) ); - const paidPayments = await dbAll( + const revenueRow = await dbGet( (db as any) - .select() + .select({ total: sql`COALESCE(SUM(${(payments as any).amount}), 0)` }) .from(payments) .where(eq((payments as any).status, 'paid')) ); - const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0); + const totalRevenue = Number(revenueRow?.total || 0); const newContacts = await dbGet( (db as any) @@ -110,56 +110,52 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => // Get analytics data (admin) adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { - // Get events with ticket counts + // Get events with ticket counts using grouped aggregates (avoids 3 queries per event). const allEvents = await dbAll((db as any).select().from(events)); - - const eventStats = await Promise.all( - allEvents.map(async (event: any) => { - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where(eq((tickets as any).eventId, event.id)) - ); - - const confirmedCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed'), - ne((tickets as any).isGuest, 1) - ) - ) - ); - - const checkedInCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'checked_in'), - ne((tickets as any).isGuest, 1) - ) - ) - ); - - return { - id: event.id, - title: event.title, - date: event.startDatetime, - capacity: event.capacity, - totalBookings: ticketCount?.count || 0, - confirmedBookings: confirmedCount?.count || 0, - checkedIn: checkedInCount?.count || 0, - revenue: (confirmedCount?.count || 0) * event.price, - }; - }) + + const totalRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) + .from(tickets) + .groupBy((tickets as any).eventId) ); + const confirmedRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) + .from(tickets) + .where(and(eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1))) + .groupBy((tickets as any).eventId) + ); + const checkedInRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) + .from(tickets) + .where(and(eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1))) + .groupBy((tickets as any).eventId) + ); + + const toMap = (rows: any[]) => { + const m = new Map(); + for (const r of rows) m.set(r.eventId, Number(r.count) || 0); + return m; + }; + const totalMap = toMap(totalRows); + const confirmedMap = toMap(confirmedRows); + const checkedInMap = toMap(checkedInRows); + + const eventStats = allEvents.map((event: any) => { + const confirmedBookings = confirmedMap.get(event.id) || 0; + return { + id: event.id, + title: event.title, + date: event.startDatetime, + capacity: event.capacity, + totalBookings: totalMap.get(event.id) || 0, + confirmedBookings, + checkedIn: checkedInMap.get(event.id) || 0, + revenue: confirmedBookings * event.price, + }; + }); return c.json({ analytics: { @@ -179,30 +175,25 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { } const ticketList = await dbAll(query); + + const userIds = [...new Set(ticketList.map((t: any) => t.userId).filter(Boolean))]; + const eventIds = [...new Set(ticketList.map((t: any) => t.eventId).filter(Boolean))]; + const ticketIds = ticketList.map((t: any) => t.id); + + const [userRows, eventRows, paymentRows] = await Promise.all([ + userIds.length ? dbAll((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]), + eventIds.length ? dbAll((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]), + ticketIds.length ? dbAll((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) : Promise.resolve([]), + ]); + + const usersById = new Map(userRows.map((u: any) => [u.id, u])); + const eventsById = new Map(eventRows.map((e: any) => [e.id, e])); + const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p])); - // Get user and event details for each ticket - const enrichedTickets = await Promise.all( - ticketList.map(async (ticket: any) => { - const user = await dbGet( - (db as any) - .select() - .from(users) - .where(eq((users as any).id, ticket.userId)) - ); - - const event = await dbGet( - (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - ); - - const payment = await dbGet( - (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - ); + const enrichedTickets = ticketList.map((ticket: any) => { + const user = usersById.get(ticket.userId); + const event = eventsById.get(ticket.eventId); + const payment = paymentsByTicketId.get(ticket.id); return { ticketId: ticket.id, @@ -218,8 +209,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => { paymentAmount: payment?.amount, createdAt: ticket.createdAt, }; - }) - ); + }); return c.json({ tickets: enrichedTickets }); }); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index ad34c63..74e1a47 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -14,10 +14,17 @@ import { createMagicLinkToken, verifyMagicLinkToken, invalidateAllUserSessions, + bumpTokenVersion, requireAuth, + getUserPasswordHash, } from '../lib/auth.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { sendEmail } from '../lib/email.js'; +import { rateLimitMiddleware } from '../lib/rateLimit.js'; + +// Per-IP rate limit for sensitive auth endpoints (registration, login, and all +// email-dispatching flows) to curb credential stuffing and email flooding. +const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' }); // User type that includes all fields (some added in schema updates) type AuthUser = User & { @@ -97,8 +104,7 @@ const passwordResetSchema = z.object({ const claimAccountSchema = z.object({ token: z.string(), - password: z.string().min(10, 'Password must be at least 10 characters').optional(), - googleId: z.string().optional(), + password: z.string().min(10, 'Password must be at least 10 characters'), }); const changePasswordSchema = z.object({ @@ -111,7 +117,7 @@ const googleAuthSchema = z.object({ }); // Register -auth.post('/register', zValidator('json', registerSchema), async (c) => { +auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => { const data = c.req.valid('json'); // Validate password strength @@ -161,7 +167,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => { await (db as any).insert(users).values(newUser); - const token = await createToken(id, data.email, newUser.role); + const token = await createToken(id, data.email, newUser.role, 0); const refreshToken = await createRefreshToken(id); return c.json({ @@ -179,7 +185,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => { }); // Login with email/password -auth.post('/login', zValidator('json', loginSchema), async (c) => { +auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => { const data = c.req.valid('json'); // Check rate limit @@ -224,7 +230,7 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => { // Clear failed attempts on successful login clearFailedAttempts(data.email); - const token = await createToken(user.id, user.email, user.role); + const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); const refreshToken = await createRefreshToken(user.id); return c.json({ @@ -244,7 +250,7 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => { }); // Request magic link login -auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => { +auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => { const { email } = c.req.valid('json'); const user = await dbGet( @@ -285,7 +291,7 @@ auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), asy }); // Verify magic link and login -auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => { +auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => { const { token } = c.req.valid('json'); const verification = await verifyMagicLinkToken(token, 'login'); @@ -302,7 +308,7 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async return c.json({ error: 'Invalid token' }, 400); } - const authToken = await createToken(user.id, user.email, user.role); + const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); const refreshToken = await createRefreshToken(user.id); return c.json({ @@ -322,7 +328,7 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async }); // Request password reset -auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => { +auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => { const { email } = c.req.valid('json'); const user = await dbGet( @@ -363,7 +369,7 @@ auth.post('/password-reset/request', zValidator('json', passwordResetRequestSche }); // Reset password -auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => { +auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => { const { token, password } = c.req.valid('json'); // Validate password strength @@ -389,14 +395,15 @@ auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), as }) .where(eq((users as any).id, verification.userId)); - // Invalidate all existing sessions for security + // Invalidate all existing sessions/JWTs for security await invalidateAllUserSessions(verification.userId!); + await bumpTokenVersion(verification.userId!); return c.json({ message: 'Password reset successfully. Please log in with your new password.' }); }); // Claim unclaimed account -auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => { +auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => { const { email } = c.req.valid('json'); const user = await dbGet( @@ -411,8 +418,8 @@ auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), return c.json({ error: 'Account is already claimed' }, 400); } - // Create claim token (expires in 24 hours) - const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60); + // Create claim token (expires in 1 hour) + const token = await createMagicLinkToken(user.id, 'claim_account', 60); const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`; // Send email @@ -425,7 +432,7 @@ auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema),

An account was created for you during booking. Click below to set up your login credentials.

Claim Account

Or copy this link: ${claimLink}

-

This link expires in 24 hours.

+

This link expires in 1 hour.

`, }); } catch (error) { @@ -436,12 +443,8 @@ auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), }); // Complete account claim -auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => { - const { token, password, googleId } = c.req.valid('json'); - - if (!password && !googleId) { - return c.json({ error: 'Please provide either a password or link a Google account' }, 400); - } +auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => { + const { token, password } = c.req.valid('json'); const verification = await verifyMagicLinkToken(token, 'claim_account'); @@ -449,25 +452,21 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn return c.json({ error: verification.error }, 400); } + const passwordValidation = validatePassword(password); + if (!passwordValidation.valid) { + return c.json({ error: passwordValidation.error }, 400); + } + const now = getNow(); + // Only set a password here. Linking a Google account requires a verified Google + // ID token via /google; we never trust a client-supplied googleId. const updates: Record = { isClaimed: toDbBool(true), accountStatus: 'active', + password: await hashPassword(password), updatedAt: now, }; - if (password) { - const passwordValidation = validatePassword(password); - if (!passwordValidation.valid) { - return c.json({ error: passwordValidation.error }, 400); - } - updates.password = await hashPassword(password); - } - - if (googleId) { - updates.googleId = googleId; - } - await (db as any) .update(users) .set(updates) @@ -477,7 +476,7 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn (db as any).select().from(users).where(eq((users as any).id, verification.userId)) ); - const authToken = await createToken(user.id, user.email, user.role); + const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); const refreshToken = await createRefreshToken(user.id); return c.json({ @@ -498,13 +497,14 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn }); // Google OAuth login/register -auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { +auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => { const { credential } = c.req.valid('json'); try { - // Verify Google token - // In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token - const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`); + // Verify the Google ID token. Google's tokeninfo endpoint validates the + // signature and expiry server-side; we additionally enforce the audience so a + // token minted for a different OAuth client cannot be replayed against us. + const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`); if (!response.ok) { return c.json({ error: 'Invalid Google token' }, 400); @@ -515,11 +515,29 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { email: string; name: string; email_verified: string; + aud?: string; + exp?: string; }; - - if (googleData.email_verified !== 'true') { + + // email_verified can be returned as boolean true or string "true" + if (String(googleData.email_verified) !== 'true') { return c.json({ error: 'Google email not verified' }, 400); } + + // Enforce audience when a client ID is configured (closes token-confusion attacks) + const expectedAud = process.env.GOOGLE_CLIENT_ID; + if (expectedAud) { + if (googleData.aud !== expectedAud) { + return c.json({ error: 'Invalid Google token audience' }, 400); + } + } else { + console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.'); + } + + // Reject expired tokens (defense-in-depth; tokeninfo also rejects them) + if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) { + return c.json({ error: 'Google token expired' }, 400); + } const { sub: googleId, email, name } = googleData; @@ -584,7 +602,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { user = newUser; } - const authToken = await createToken(user.id, user.email, user.role); + const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0); const refreshToken = await createRefreshToken(user.id); return c.json({ @@ -643,8 +661,9 @@ auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSc } // Verify current password if user has one - if (user.password) { - const validPassword = await verifyPassword(currentPassword, user.password); + const existingHash = await getUserPasswordHash(user.id); + if (existingHash) { + const validPassword = await verifyPassword(currentPassword, existingHash); if (!validPassword) { return c.json({ error: 'Current password is incorrect' }, 400); } @@ -660,12 +679,25 @@ auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSc updatedAt: now, }) .where(eq((users as any).id, user.id)); + + // Invalidate all previously issued JWTs so a stolen old token can't outlive the change, + // then hand the current client a fresh token so it stays logged in on this device. + await bumpTokenVersion(user.id); + const refreshedUser = await dbGet( + (db as any).select().from(users).where(eq((users as any).id, user.id)) + ); + const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0); - return c.json({ message: 'Password changed successfully' }); + return c.json({ message: 'Password changed successfully', token: newToken }); }); -// Logout (client-side token removal, but we can log the action) +// Logout - invalidate all previously issued JWTs for this user (logout everywhere) auth.post('/logout', async (c) => { + const user = await getAuthUser(c); + if (user) { + await invalidateAllUserSessions(user.id); + await bumpTokenVersion(user.id); + } return c.json({ message: 'Logged out successfully' }); }); diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts index a86093c..9e26143 100644 --- a/backend/src/routes/contacts.ts +++ b/backend/src/routes/contacts.ts @@ -6,9 +6,14 @@ import { eq, desc } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { generateId, getNow } from '../lib/utils.js'; import { emailService } from '../lib/email.js'; +import { rateLimitMiddleware } from '../lib/rateLimit.js'; const contactsRouter = new Hono(); +// Per-IP rate limit for public, unauthenticated write endpoints (contact form, +// newsletter subscribe/unsubscribe) to prevent spam and email flooding. +const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' }); + // ==================== Sanitization Helpers ==================== /** @@ -33,14 +38,14 @@ function sanitizeHeaderValue(str: string): string { } const createContactSchema = z.object({ - name: z.string().min(2), - email: z.string().email(), - message: z.string().min(10), + name: z.string().min(2).max(200), + email: z.string().email().max(254), + message: z.string().min(10).max(5000), }); const subscribeSchema = z.object({ - email: z.string().email(), - name: z.string().optional(), + email: z.string().email().max(254), + name: z.string().max(200).optional(), }); const updateContactSchema = z.object({ @@ -48,7 +53,7 @@ const updateContactSchema = z.object({ }); // Submit contact form (public) -contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => { +contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => { const data = c.req.valid('json'); const now = getNow(); const id = generateId(); @@ -125,7 +130,7 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => { }); // Subscribe to newsletter (public) -contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => { +contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => { const data = c.req.valid('json'); // Check if already subscribed @@ -166,28 +171,30 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) }); // Unsubscribe from newsletter (public) -contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => { +contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => { const { email } = c.req.valid('json'); const existing = await dbGet( (db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email)) ); - if (!existing) { - return c.json({ error: 'Email not found' }, 404); + // Always return the same response whether or not the address exists, to avoid + // leaking which emails are subscribed (enumeration). + if (existing && existing.status !== 'unsubscribed') { + await (db as any) + .update(emailSubscribers) + .set({ status: 'unsubscribed' }) + .where(eq((emailSubscribers as any).id, existing.id)); } - await (db as any) - .update(emailSubscribers) - .set({ status: 'unsubscribed' }) - .where(eq((emailSubscribers as any).id, existing.id)); - - return c.json({ message: 'Successfully unsubscribed' }); + return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' }); }); // Get all contacts (admin) contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const status = c.req.query('status'); + const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500); + const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); let query = (db as any).select().from(contacts); @@ -195,7 +202,9 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { query = query.where(eq((contacts as any).status, status)); } - const result = await dbAll(query.orderBy(desc((contacts as any).createdAt))); + const result = await dbAll( + query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset) + ); return c.json({ contacts: result }); }); @@ -255,6 +264,8 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => { // Get all subscribers (admin) contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => { const status = c.req.query('status'); + const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000); + const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); let query = (db as any).select().from(emailSubscribers); @@ -262,7 +273,9 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy query = query.where(eq((emailSubscribers as any).status, status)); } - const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt))); + const result = await dbAll( + query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset) + ); return c.json({ subscribers: result }); }); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 9895c5c..4e2ec47 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -2,8 +2,8 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js'; -import { eq, desc, and, gt, sql } from 'drizzle-orm'; -import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js'; +import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm'; +import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js'; import { generateId, getNow } from '../lib/utils.js'; // User type that includes all fields (some added in schema updates) @@ -37,6 +37,8 @@ dashboard.get('/profile', async (c) => { const now = new Date(); const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24)); + const hasPassword = !!(await getUserPasswordHash(user.id)); + return c.json({ profile: { id: user.id, @@ -47,7 +49,7 @@ dashboard.get('/profile', async (c) => { rucNumber: user.rucNumber, isClaimed: user.isClaimed, accountStatus: user.accountStatus, - hasPassword: !!user.password, + hasPassword, hasGoogleLinked: !!user.googleId, memberSince: user.createdAt, membershipDays, @@ -103,34 +105,31 @@ dashboard.get('/tickets', async (c) => { .where(eq((tickets as any).userId, user.id)) .orderBy(desc((tickets as any).createdAt)) ); + + // Batch-fetch related events, payments, and invoices (avoids N+1 per ticket). + const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))]; + const ticketIds = userTickets.map((t: any) => t.id); + + const eventRows = eventIds.length + ? await dbAll((db as any).select().from(events).where(inArray((events as any).id, eventIds))) + : []; + const paymentRows = ticketIds.length + ? await dbAll((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) + : []; + + const eventsById = new Map(eventRows.map((e: any) => [e.id, e])); + const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p])); + + const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id); + const invoiceRows = paidPaymentIds.length + ? await dbAll((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds))) + : []; + const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv])); - // Get event details for each ticket - const ticketsWithEvents = await Promise.all( - userTickets.map(async (ticket: any) => { - const event = await dbGet( - (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - ); - - const payment = await dbGet( - (db as any) - .select() - .from(payments) - .where(eq((payments as any).ticketId, ticket.id)) - ); - - // Check for invoice - let invoice: any = null; - if (payment && payment.status === 'paid') { - invoice = await dbGet( - (db as any) - .select() - .from(invoices) - .where(eq((invoices as any).paymentId, payment.id)) - ); - } + const ticketsWithEvents = userTickets.map((ticket: any) => { + const event = eventsById.get(ticket.eventId); + const payment = paymentsByTicketId.get(ticket.id); + const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null; return { ...ticket, @@ -162,8 +161,7 @@ dashboard.get('/tickets', async (c) => { createdAt: invoice.createdAt, } : null, }; - }) - ); + }); return c.json({ tickets: ticketsWithEvents }); }); @@ -452,13 +450,22 @@ dashboard.delete('/sessions/:id', async (c) => { return c.json({ message: 'Session revoked' }); }); -// Revoke all sessions (logout everywhere) +// Revoke all sessions (logout everywhere). Bumping the token version invalidates +// every previously issued JWT for this user, which is the actual enforcement +// mechanism (auth is stateless JWT, not DB-session based). dashboard.post('/sessions/revoke-all', async (c) => { const user = (c as any).get('user') as AuthUser; await invalidateAllUserSessions(user.id); + await bumpTokenVersion(user.id); + + // Issue a fresh token so the current device stays signed in + const refreshed = await dbGet( + (db as any).select().from(users).where(eq((users as any).id, user.id)) + ); + const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0); - return c.json({ message: 'All sessions revoked. Please log in again.' }); + return c.json({ message: 'All other sessions revoked.', token }); }); // Set password (for users without one) @@ -471,7 +478,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c) const { password } = c.req.valid('json'); // Check if user already has a password - if (user.password) { + if (await getUserPasswordHash(user.id)) { return c.json({ error: 'Password already set. Use change password instead.' }, 400); } @@ -502,7 +509,7 @@ dashboard.post('/unlink-google', async (c) => { return c.json({ error: 'Google account not linked' }, 400); } - if (!user.password) { + if (!(await getUserPasswordHash(user.id))) { return c.json({ error: 'Cannot unlink Google without a password set' }, 400); } diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index 281e900..68dd6c5 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -1,4 +1,6 @@ import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js'; import { eq, desc, and, or, sql } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; @@ -9,6 +11,50 @@ import { getQueueStatus } from '../lib/emailQueue.js'; const emailsRouter = new Hono(); +const slugPattern = /^[a-z0-9-]+$/; + +const createTemplateSchema = z.object({ + name: z.string().min(1).max(255), + slug: z.string().min(1).max(100).regex(slugPattern), + subject: z.string().min(1).max(500), + subjectEs: z.string().max(500).optional().nullable(), + bodyHtml: z.string().min(1).max(200_000), + bodyHtmlEs: z.string().max(200_000).optional().nullable(), + bodyText: z.string().max(200_000).optional().nullable(), + bodyTextEs: z.string().max(200_000).optional().nullable(), + description: z.string().max(2000).optional().nullable(), + variables: z.array(z.any()).max(100).optional(), +}); + +const updateTemplateSchema = createTemplateSchema.partial().extend({ + isActive: z.boolean().optional(), +}); + +const sendCustomEmailSchema = z.object({ + to: z.string().email().max(254), + toName: z.string().max(200).optional(), + subject: z.string().min(1).max(500), + bodyHtml: z.string().min(1).max(200_000), + bodyText: z.string().max(200_000).optional(), + eventId: z.string().optional(), +}); + +const emailLogsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +// Safely parse a stored JSON variables column; a corrupt row must not 500 the route. +function safeParseVariables(raw: any): any[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + // ==================== Template Routes ==================== // Get all email templates @@ -20,7 +66,7 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => // Parse variables JSON for each template const parsedTemplates = templates.map((t: any) => ({ ...t, - variables: t.variables ? JSON.parse(t.variables) : [], + variables: safeParseVariables(t.variables), isSystem: Boolean(t.isSystem), isActive: Boolean(t.isActive), })); @@ -46,7 +92,7 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c return c.json({ template: { ...template, - variables: template.variables ? JSON.parse(template.variables) : [], + variables: safeParseVariables(template.variables), isSystem: Boolean(template.isSystem), isActive: Boolean(template.isActive), } @@ -54,14 +100,10 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c }); // Create new email template -emailsRouter.post('/templates', requireAuth(['admin']), async (c) => { - const body = await c.req.json(); +emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', createTemplateSchema), async (c) => { + const body = c.req.valid('json'); const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body; - if (!name || !slug || !subject || !bodyHtml) { - return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400); - } - // Check if slug already exists const existing = await dbGet( (db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug)) @@ -104,9 +146,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => { }); // Update email template -emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => { +emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', updateTemplateSchema), async (c) => { const { id } = c.req.param(); - const body = await c.req.json(); + const body = c.req.valid('json'); const existing = await dbGet( (db as any) @@ -130,13 +172,14 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => { } for (const field of allowedFields) { - if (body[field] !== undefined) { + const value = (body as Record)[field]; + if (value !== undefined) { if (field === 'variables') { - updateData[field] = JSON.stringify(body[field]); + updateData[field] = JSON.stringify(value); } else if (field === 'isActive') { - updateData[field] = body[field] ? 1 : 0; + updateData[field] = value ? 1 : 0; } else { - updateData[field] = body[field]; + updateData[field] = value; } } } @@ -156,7 +199,7 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => { return c.json({ template: { ...updated, - variables: updated.variables ? JSON.parse(updated.variables) : [], + variables: safeParseVariables(updated.variables), isSystem: Boolean(updated.isSystem), isActive: Boolean(updated.isActive), }, @@ -203,16 +246,22 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a const body = await c.req.json(); const { templateSlug, customVariables, recipientFilter } = body; - if (!templateSlug) { + if (!templateSlug || typeof templateSlug !== 'string') { return c.json({ error: 'Template slug is required' }, 400); } + + const allowedFilters = ['confirmed', 'pending', 'all', 'checked_in']; + const filter = recipientFilter || 'confirmed'; + if (!allowedFilters.includes(filter)) { + return c.json({ error: `Invalid recipientFilter. Allowed: ${allowedFilters.join(', ')}` }, 400); + } // Queue emails for background processing instead of sending synchronously const result = await emailService.queueEventEmails({ eventId, templateSlug, customVariables, - recipientFilter: recipientFilter || 'confirmed', + recipientFilter: filter, sentBy: user?.id, }); @@ -220,14 +269,9 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a }); // Send custom email to specific recipients -emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => { +emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidator('json', sendCustomEmailSchema), async (c) => { const user = (c as any).get('user'); - const body = await c.req.json(); - const { to, toName, subject, bodyHtml, bodyText, eventId } = body; - - if (!to || !subject || !bodyHtml) { - return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400); - } + const { to, toName, subject, bodyHtml, bodyText, eventId } = c.req.valid('json'); const result = await emailService.sendCustomEmail({ to, @@ -272,7 +316,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => : template.bodyHtml; const finalSubject = replaceTemplateVariables(subject, allVariables); - const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables); + const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true); const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject }); return c.json({ @@ -288,8 +332,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); const status = c.req.query('status'); const search = c.req.query('search'); - const limit = parseInt(c.req.query('limit') || '50'); - const offset = parseInt(c.req.query('offset') || '0'); + // Clamp pagination so a NaN / out-of-range value can't produce undefined query behaviour. + const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1), 200); + const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); let query = (db as any).select().from(emailLogs); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index c7ed4ad..8561381 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -127,8 +127,13 @@ const baseEventSchema = z.object({ currency: z.string().default('PYG'), capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50), status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'), - // Accept relative paths (/uploads/...) or full URLs - bannerUrl: z.string().optional().nullable().or(z.literal('')), + // Accept relative paths (/uploads/...) or http(s) URLs only — reject schemes like + // javascript:/data: that could be reflected into an href/src on the frontend. + bannerUrl: z.string() + .refine((v) => v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), { + message: 'Banner URL must be a relative path or an http(s) URL', + }) + .optional().nullable().or(z.literal('')), // External booking support - accept boolean or number (0/1 from DB) externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false), externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')), @@ -166,51 +171,59 @@ const updateEventSchema = baseEventSchema.partial().refine( eventsRouter.get('/', async (c) => { const status = c.req.query('status'); const upcoming = c.req.query('upcoming'); - - let query = (db as any).select().from(events); - - if (status) { - query = query.where(eq((events as any).status, status)); - } - + + // Only privileged users may see non-public events (drafts, archived, etc.). + // Anonymous/regular callers are restricted to published events regardless of + // any client-supplied status filter, so drafts cannot leak. + const authUser: any = await getAuthUser(c); + const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); + + const conditions: any[] = []; + if (upcoming === 'true') { - const now = getNow(); - query = query.where( - and( - eq((events as any).status, 'published'), - gte((events as any).startDatetime, now) - ) - ); + // Upcoming feed is always published + future-dated, for everyone. + conditions.push(eq((events as any).status, 'published')); + conditions.push(gte((events as any).startDatetime, getNow())); + } else if (isPrivileged) { + // Admins/staff may filter by any status (or list everything when unset). + if (status) { + conditions.push(eq((events as any).status, status)); + } + } else { + // Public listing: published events only, regardless of any status param. + conditions.push(eq((events as any).status, 'published')); + } + + let query = (db as any).select().from(events); + if (conditions.length > 0) { + query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions)); } - const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); - - // Get ticket counts for each event - const eventsWithCounts = await Promise.all( - result.map(async (event: any) => { - // Count confirmed AND checked_in tickets (checked_in were previously confirmed) - // This ensures check-in doesn't affect capacity/spots_left - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - - const normalized = normalizeEvent(event); - const bookedCount = ticketCount?.count || 0; - return { - ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), - }; - }) + const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); + + // Single grouped query for booked counts across all events (avoids N+1: previously + // this ran one COUNT query per event). + const countRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) + .from(tickets) + .where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`) + .groupBy((tickets as any).eventId) ); + const countByEvent = new Map(); + for (const row of countRows) { + countByEvent.set(row.eventId, Number(row.count) || 0); + } + + const eventsWithCounts = result.map((event: any) => { + const normalized = normalizeEvent(event); + const bookedCount = countByEvent.get(event.id) || 0; + return { + ...normalized, + bookedCount, + availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + }; + }); return c.json({ events: eventsWithCounts }); }); @@ -223,6 +236,15 @@ eventsRouter.get('/:id', async (c) => { if (!event) { return c.json({ error: 'Event not found' }, 404); } + + // Draft events are only visible to privileged users (admin preview); hide from public. + if ((event as any).status === 'draft') { + const authUser: any = await getAuthUser(c); + const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role); + if (!isPrivileged) { + return c.json({ error: 'Event not found' }, 404); + } + } // Count confirmed AND checked_in tickets (checked_in were previously confirmed) // This ensures check-in doesn't affect capacity/spots_left diff --git a/backend/src/routes/faq.ts b/backend/src/routes/faq.ts index 86f5b2f..67b7370 100644 --- a/backend/src/routes/faq.ts +++ b/backend/src/routes/faq.ts @@ -6,6 +6,22 @@ import { getNow, generateId } from '../lib/utils.js'; const faqRouter = new Hono(); +// Upper bounds for admin-supplied FAQ content (guards against accidental/abusive huge payloads) +const MAX_QUESTION_LEN = 1000; +const MAX_ANSWER_LEN = 20000; + +// Returns an error message if any provided field exceeds its limit, else null. +function faqLengthError(fields: { question?: any; questionEs?: any; answer?: any; answerEs?: any }): string | null { + const check = (v: any, max: number, label: string) => + typeof v === 'string' && v.length > max ? `${label} must be at most ${max} characters` : null; + return ( + check(fields.question, MAX_QUESTION_LEN, 'Question') || + check(fields.questionEs, MAX_QUESTION_LEN, 'Question (ES)') || + check(fields.answer, MAX_ANSWER_LEN, 'Answer') || + check(fields.answerEs, MAX_ANSWER_LEN, 'Answer (ES)') + ); +} + // ==================== Public Routes ==================== // Get FAQ list for public (only enabled; optional filter for homepage) @@ -98,6 +114,11 @@ faqRouter.post('/admin', requireAuth(['admin']), async (c) => { return c.json({ error: 'Question and answer (EN) are required' }, 400); } + const lengthError = faqLengthError({ question, questionEs, answer, answerEs }); + if (lengthError) { + return c.json({ error: lengthError }, 400); + } + const now = getNow(); const id = generateId(); @@ -157,6 +178,11 @@ faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => { return c.json({ error: 'FAQ not found' }, 404); } + const lengthError = faqLengthError({ question, questionEs, answer, answerEs }); + if (lengthError) { + return c.json({ error: lengthError }, 400); + } + const updateData: Record = { updatedAt: getNow(), }; @@ -209,6 +235,15 @@ faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => { return c.json({ error: 'ids array is required' }, 400); } + // Verify every id exists before applying ranks (prevents silent no-ops on bad input). + const existingRows = await dbAll( + (db as any).select({ id: (faqQuestions as any).id }).from(faqQuestions) + ); + const existingIds = new Set(existingRows.map((r: any) => r.id)); + if (ids.some((id: string) => !existingIds.has(id))) { + return c.json({ error: 'One or more FAQ ids are invalid' }, 400); + } + const now = getNow(); for (let i = 0; i < ids.length; i++) { await (db as any) diff --git a/backend/src/routes/legal-pages.ts b/backend/src/routes/legal-pages.ts index 321071f..6132094 100644 --- a/backend/src/routes/legal-pages.ts +++ b/backend/src/routes/legal-pages.ts @@ -162,6 +162,13 @@ legalPagesRouter.get('/', async (c) => { legalPagesRouter.get('/:slug', async (c) => { const { slug } = c.req.param(); const locale = c.req.query('locale') || 'en'; + + // Reject anything that isn't a simple slug. The filesystem fallback below builds a + // path from this value, so an unconstrained slug (e.g. "../../etc/passwd") would + // allow path traversal / arbitrary file reads. + if (!/^[a-z0-9-]+$/.test(slug)) { + return c.json({ error: 'Legal page not found' }, 404); + } // First try to get from database const page = await dbGet( @@ -275,6 +282,17 @@ legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => { if (!enContent && !esContent) { return c.json({ error: 'At least one language content is required' }, 400); } + + // Bound the sizes of admin-supplied content to avoid unbounded payloads. + const MAX_CONTENT_LEN = 200000; // ~200 KB of markdown per language + const MAX_TITLE_LEN = 255; + const tooLong = (v: any, max: number) => typeof v === 'string' && v.length > max; + if (tooLong(enContent, MAX_CONTENT_LEN) || tooLong(esContent, MAX_CONTENT_LEN)) { + return c.json({ error: `Content must be at most ${MAX_CONTENT_LEN} characters` }, 400); + } + if (tooLong(title, MAX_TITLE_LEN) || tooLong(titleEs, MAX_TITLE_LEN)) { + return c.json({ error: `Title must be at most ${MAX_TITLE_LEN} characters` }, 400); + } const existing = await dbGet( (db as any) diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index b433b33..ce29569 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { db, dbGet, dbAll, tickets, payments } from '../db/index.js'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getNow } from '../lib/utils.js'; import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; @@ -111,13 +111,23 @@ function stopBackgroundChecker(ticketId: string) { */ lnbitsRouter.post('/webhook', async (c) => { try { + // Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the + // webhook URL must carry a matching ?token=... (set when the invoice is created). + const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || ''; + if (webhookSecret) { + const provided = c.req.query('token') || c.req.header('x-webhook-secret') || ''; + if (provided !== webhookSecret) { + console.warn('LNbits webhook rejected: invalid or missing secret'); + return c.json({ received: true, processed: false }, 401); + } + } + const payload: LNbitsWebhookPayload = await c.req.json(); + // Log identifiers only (no full payload / PII) console.log('LNbits webhook received:', { paymentHash: payload.payment_hash, status: payload.status, - amount: payload.amount, - extra: payload.extra, }); // Verify the payment is actually complete by checking with LNbits @@ -135,6 +145,20 @@ lnbitsRouter.post('/webhook', async (c) => { return c.json({ received: true, processed: false }, 200); } + // CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a + // valid paid hash from any other invoice could be replayed with an arbitrary + // ticketId to confirm tickets for free. + const ticketPayment = await dbGet( + (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) + ); + if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) { + console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', { + ticketId, + paymentHash: payload.payment_hash, + }); + return c.json({ received: true, processed: false }, 200); + } + // Stop background checker since webhook confirmed payment stopBackgroundChecker(ticketId); @@ -186,15 +210,13 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`); } - // Confirm all tickets in the booking + // Confirm all tickets in the booking (idempotent: only flip pending -> confirmed) for (const ticket of ticketsToConfirm) { - // Update ticket status to confirmed await (db as any) .update(tickets) .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, ticket.id)); + .where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending'))); - // Update payment status to paid await (db as any) .update(payments) .set({ @@ -203,7 +225,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { paidAt: now, updatedAt: now, }) - .where(eq((payments as any).ticketId, ticket.id)); + .where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending'))); console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`); } diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index b0b75b6..a2af83e 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -1,19 +1,56 @@ import { Hono } from 'hono'; import { db, dbGet, dbAll, media } from '../db/index.js'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { generateId, getNow } from '../lib/utils.js'; import { writeFile, mkdir, unlink } from 'fs/promises'; import { existsSync } from 'fs'; -import { join, extname } from 'path'; +import { join } from 'path'; const mediaRouter = new Hono(); const UPLOAD_DIR = './uploads'; -const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']; const MAX_FILE_SIZE = (Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB +/** + * Detect a real image type from the file's magic bytes (content sniffing). + * Returns the canonical mime + extension, or null if the content is not an + * allowed image. We deliberately ignore the client-supplied filename and + * Content-Type so an attacker cannot store e.g. an .html/.svg payload. + */ +function detectImageType(buf: Buffer): { mime: string; ext: string } | null { + if (buf.length < 12) return null; + + // JPEG: FF D8 FF + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { + return { mime: 'image/jpeg', ext: '.jpg' }; + } + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 && + buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a + ) { + return { mime: 'image/png', ext: '.png' }; + } + // GIF: "GIF87a" / "GIF89a" + if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') { + return { mime: 'image/gif', ext: '.gif' }; + } + // WEBP: "RIFF"...."WEBP" + if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') { + return { mime: 'image/webp', ext: '.webp' }; + } + // AVIF / HEIF: "....ftyp" with an avif/heic brand + if (buf.toString('ascii', 4, 8) === 'ftyp') { + const brand = buf.toString('ascii', 8, 12); + if (brand === 'avif' || brand === 'avis') { + return { mime: 'image/avif', ext: '.avif' }; + } + } + return null; +} + // Ensure upload directory exists async function ensureUploadDir() { if (!existsSync(UPLOAD_DIR)) { @@ -31,28 +68,30 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => { return c.json({ error: 'No file provided' }, 400); } - // Validate file type - if (!ALLOWED_TYPES.includes(file.type)) { - return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400); - } - - // Validate file size + // Validate file size (cheap check before reading the whole buffer) if (file.size > MAX_FILE_SIZE) { const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10; return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400); } + // Read the bytes and validate the *content* (not the client-provided type/name) + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const detected = detectImageType(buffer); + if (!detected) { + return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400); + } + await ensureUploadDir(); - // Generate unique filename + // Generate unique filename using the *detected* extension (ignore client filename) const id = generateId(); - const ext = extname(file.name) || '.jpg'; - const filename = `${id}${ext}`; + const filename = `${id}${detected.ext}`; const filepath = join(UPLOAD_DIR, filename); // Write file - const arrayBuffer = await file.arrayBuffer(); - await writeFile(filepath, Buffer.from(arrayBuffer)); + await writeFile(filepath, buffer); // Get related info from form data const relatedId = body['relatedId'] as string | undefined; @@ -128,17 +167,20 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => { mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const relatedType = c.req.query('relatedType'); const relatedId = c.req.query('relatedId'); + const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500); + const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); + // Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle. + const conditions: any[] = []; + if (relatedType) conditions.push(eq((media as any).relatedType, relatedType)); + if (relatedId) conditions.push(eq((media as any).relatedId, relatedId)); + let query = (db as any).select().from(media); - - if (relatedType) { - query = query.where(eq((media as any).relatedType, relatedType)); - } - if (relatedId) { - query = query.where(eq((media as any).relatedId, relatedId)); + if (conditions.length > 0) { + query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions)); } - const result = await dbAll(query); + const result = await dbAll(query.limit(limit).offset(offset)); return c.json({ media: result }); }); diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 33fd275..27b4496 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -1,9 +1,9 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js'; +import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js'; import { eq } from 'drizzle-orm'; -import { requireAuth } from '../lib/auth.js'; +import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js'; const paymentOptionsRouter = new Hono(); @@ -40,6 +40,23 @@ const updatePaymentOptionsSchema = z.object({ allowDuplicateBookings: booleanOrNumber.optional(), }); +/** Strip bank account numbers from payment options for anonymous callers. */ +function publicPaymentOptions(merged: Record) { + return { + ...merged, + bankName: null, + bankAccountHolder: null, + bankAccountNumber: null, + bankAlias: null, + bankPhone: null, + tpagoLink: null, + tpagoLink2: null, + tpagoLink3: null, + tpagoLink4: null, + tpagoLink5: null, + }; +} + // Schema for event-level overrides const updateEventOverridesSchema = z.object({ tpagoEnabled: booleanOrNumber.optional().nullable(), @@ -151,6 +168,7 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP // Get payment options for a specific event (merged with global) paymentOptionsRouter.get('/event/:eventId', async (c) => { const eventId = c.req.param('eventId'); + const ticketId = c.req.query('ticketId'); // Get the event first to verify it exists const event = await dbGet( @@ -229,8 +247,24 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => { cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs, }; + // Full bank/TPago credentials are only returned when the caller proves they hold + // a valid ticket for this event (the ticket UUID is the booking capability token), + // or when an authenticated admin/organizer requests them. + let revealSensitive = false; + const authUser: any = await getAuthUser(c); + if (authUser && ['admin', 'organizer'].includes(authUser.role)) { + revealSensitive = true; + } else if (ticketId) { + const ticket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); + if (ticket && ticket.eventId === eventId && ticket.status !== 'cancelled') { + revealSensitive = true; + } + } + return c.json({ - paymentOptions: merged, + paymentOptions: revealSensitive ? merged : publicPaymentOptions(merged), hasOverrides: !!overrides, }); }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index cd60ebe..f480479 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -162,6 +162,29 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy return c.json({ payments: enrichedPayments }); }); +// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id +paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { + const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([ + dbGet((db as any).select({ count: sql`count(*)` }).from(payments)), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))), + dbGet((db as any).select({ total: sql`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))), + ]); + + return c.json({ + stats: { + total: Number(totalRow?.count || 0), + pending: Number(pendingRow?.count || 0), + paid: Number(paidRow?.count || 0), + refunded: Number(refundedRow?.count || 0), + failed: Number(failedRow?.count || 0), + totalRevenue: Number(revenueRow?.total || 0), + }, + }); +}); + // Get payment by ID (admin) paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => { const id = c.req.param('id'); @@ -387,26 +410,40 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat } const now = getNow(); - - // Update payment status to failed - await (db as any) - .update(payments) - .set({ - status: 'failed', - paidByAdminId: user.id, - adminNote: adminNote || payment.adminNote, - updatedAt: now, - }) - .where(eq((payments as any).id, id)); - - // Cancel the ticket - booking is no longer valid after rejection - await (db as any) - .update(tickets) - .set({ - status: 'cancelled', - updatedAt: now, - }) - .where(eq((tickets as any).id, payment.ticketId)); + + // Determine all tickets in this booking (multi-ticket bookings must be rejected together) + const rejectTicket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId)) + ); + let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : []; + if (rejectTicket?.bookingId) { + ticketsToReject = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId)) + ); + console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`); + } + + for (const t of ticketsToReject) { + // Fail the payment for each ticket in the booking + await (db as any) + .update(payments) + .set({ + status: 'failed', + paidByAdminId: user.id, + adminNote: adminNote || payment.adminNote, + updatedAt: now, + }) + .where(eq((payments as any).ticketId, (t as any).id)); + + // Cancel the ticket - booking is no longer valid after rejection + await (db as any) + .update(tickets) + .set({ + status: 'cancelled', + updatedAt: now, + }) + .where(eq((tickets as any).id, (t as any).id)); + } // Send rejection email asynchronously (for manual payment methods only, if sendEmail is true) if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) { @@ -529,56 +566,42 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => { } const now = getNow(); - - // Update payment status - await (db as any) - .update(payments) - .set({ status: 'refunded', updatedAt: now }) - .where(eq((payments as any).id, id)); - - // Cancel associated ticket - await (db as any) - .update(tickets) - .set({ status: 'cancelled' }) - .where(eq((tickets as any).id, payment.ticketId)); + + // Refund all tickets/payments in the booking (multi-ticket bookings refund together) + const refundTicket = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId)) + ); + let ticketsToRefund: any[] = refundTicket ? [refundTicket] : []; + if (refundTicket?.bookingId) { + ticketsToRefund = await dbAll( + (db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId)) + ); + console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`); + } + + for (const t of ticketsToRefund) { + // Only refund payments that were actually paid; leave others untouched + await (db as any) + .update(payments) + .set({ status: 'refunded', updatedAt: now }) + .where(and(eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'paid'))); + + await (db as any) + .update(tickets) + .set({ status: 'cancelled' }) + .where(eq((tickets as any).id, (t as any).id)); + } return c.json({ message: 'Refund processed successfully' }); }); // Payment webhook (for Stripe/MercadoPago) +// Not implemented: there is deliberately NO status mutation here. Until provider +// signature verification is implemented, accepting webhooks would let anyone forge +// a "paid" status. Returns 501 and never updates payments/tickets. paymentsRouter.post('/webhook', async (c) => { - // This would handle webhook notifications from payment providers - // Implementation depends on which provider is used - - const body = await c.req.json(); - - // Log webhook for debugging - console.log('Payment webhook received:', body); - - // TODO: Implement provider-specific webhook handling - // - Verify webhook signature - // - Update payment status - // - Update ticket status - - return c.json({ received: true }); -}); - -// Get payment statistics (admin) -paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { - const allPayments = await dbAll((db as any).select().from(payments)); - - const stats = { - total: allPayments.length, - pending: allPayments.filter((p: any) => p.status === 'pending').length, - paid: allPayments.filter((p: any) => p.status === 'paid').length, - refunded: allPayments.filter((p: any) => p.status === 'refunded').length, - failed: allPayments.filter((p: any) => p.status === 'failed').length, - totalRevenue: allPayments - .filter((p: any) => p.status === 'paid') - .reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0), - }; - - return c.json({ stats }); + console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).'); + return c.json({ error: 'Webhook handling is not implemented' }, 501); }); export default paymentsRouter; diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts index 5ff0592..e8a1389 100644 --- a/backend/src/routes/site-settings.ts +++ b/backend/src/routes/site-settings.ts @@ -17,9 +17,20 @@ interface UserContext { const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>(); // Validation schema for updating site settings +// Validate against the runtime's IANA timezone database (rejects arbitrary strings +// that later get fed into Intl.DateTimeFormat on the frontend). +const isValidTimezone = (tz: string): boolean => { + try { + Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +}; + const updateSiteSettingsSchema = z.object({ - timezone: z.string().optional(), - siteName: z.string().optional(), + timezone: z.string().refine(isValidTimezone, { message: 'Invalid timezone' }).optional(), + siteName: z.string().max(255).optional(), siteDescription: z.string().optional().nullable(), siteDescriptionEs: z.string().optional().nullable(), contactEmail: z.string().email().optional().nullable().or(z.literal('')), diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 611dcc3..007e6b4 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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): 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( @@ -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( + (db as any).select().from(paymentOptions) + ); + const eventOverrides = await dbGet( + (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId)) + ); + const mergedPaymentOptions: Record = { + 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( (db as any) .select({ count: sql`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( - (db as any) - .select() - .from(paymentOptions) - ); - - const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false; + const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false; if (!allowDuplicateBookings) { const existingTicket = await dbGet( @@ -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`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( + tx + .select({ count: sql`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( + (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() diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index bc558b1..3773812 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -50,6 +50,29 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => { return c.json({ users: result }); }); +// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id +usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { + const totalUsers = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(users) + ); + + const adminCount = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(users) + .where(eq((users as any).role, 'admin')) + ); + + return c.json({ + stats: { + total: totalUsers?.count || 0, + admins: adminCount?.count || 0, + }, + }); +}); + // Get user by ID (admin or self) usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => { const id = c.req.param('id'); @@ -286,27 +309,4 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => { } }); -// Get user statistics (admin) -usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => { - const totalUsers = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(users) - ); - - const adminCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(users) - .where(eq((users as any).role, 'admin')) - ); - - return c.json({ - stats: { - total: totalUsers?.count || 0, - admins: adminCount?.count || 0, - }, - }); -}); - export default usersRouter; diff --git a/frontend/next.config.js b/frontend/next.config.js index 264227e..f34c070 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,28 +1,51 @@ /** @type {import('next').NextConfig} */ + +// Backend origin for API/upload proxying. Configurable per environment instead of +// being hardcoded to localhost. +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; + +// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN). +const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '') + .split(',') + .map((h) => h.trim()) + .filter(Boolean) + .map((hostname) => ({ protocol: 'https', hostname })); + +const securityHeaders = [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-XSS-Protection', value: '0' }, + { key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' }, +]; + const nextConfig = { images: { - domains: ['localhost', 'images.unsplash.com'], + // Restrict remote image sources to a known allowlist instead of allowing any + // https host (which let the Next image optimizer be used as an open proxy). remotePatterns: [ - { - protocol: 'https', - hostname: '**', - }, - { - protocol: 'http', - hostname: 'localhost', - port: '3001', - }, + { protocol: 'https', hostname: 'images.unsplash.com' }, + { protocol: 'http', hostname: 'localhost', port: '3001' }, + ...extraImageHosts, ], }, + async headers() { + return [ + { + source: '/:path*', + headers: securityHeaders, + }, + ]; + }, async rewrites() { return [ { source: '/api/:path*', - destination: 'http://localhost:3001/api/:path*', + destination: `${BACKEND_URL}/api/:path*`, }, { source: '/uploads/:path*', - destination: 'http://localhost:3001/uploads/:path*', + destination: `${BACKEND_URL}/uploads/:path*`, }, ]; }, diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 8eeaf84..5d2d909 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -7,6 +7,7 @@ import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils'; +import { isSafeExternalUrl } from '@/lib/safeRedirect'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; @@ -155,8 +156,12 @@ export default function BookingPage() { return; } - // Redirect to external booking if enabled - if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) { + // Redirect to external booking if enabled (only https:// targets are allowed) + if ( + eventRes.event.externalBookingEnabled && + eventRes.event.externalBookingUrl && + isSafeExternalUrl(eventRes.event.externalBookingUrl) + ) { window.location.href = eventRes.event.externalBookingUrl; return; } @@ -473,6 +478,16 @@ export default function BookingPage() { paymentMethod: formData.paymentMethod, ticketCount, }); + // Fetch full payment credentials now that we hold a ticket capability token + try { + const { paymentOptions } = await paymentOptionsApi.getForEvent( + params.eventId as string, + primaryTicket.id + ); + setPaymentConfig(paymentOptions); + } catch { + // Keep the flags-only config from initial load if the gated fetch fails + } setStep('manual_payment'); } else { // Cash payment - go straight to success diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx index 91f6116..5a371ed 100644 --- a/frontend/src/app/(public)/booking/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -69,7 +69,7 @@ export default function BookingPaymentPage() { // Get payment config for the event if (ticketData.eventId) { - const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId); + const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id); setPaymentConfig(paymentOptions); } diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx index 9ae5864..809c09d 100644 --- a/frontend/src/app/(public)/login/page.tsx +++ b/frontend/src/app/(public)/login/page.tsx @@ -10,6 +10,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import GoogleSignInButton from '@/components/GoogleSignInButton'; import { authApi } from '@/lib/api'; +import { safeInternalPath } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; function LoginContent() { @@ -25,8 +26,8 @@ function LoginContent() { password: '', }); - // Check for redirect after login - const redirectTo = searchParams.get('redirect') || '/dashboard'; + // Check for redirect after login (only same-origin relative paths are honoured) + const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 2e2b754..66c97ed 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -1047,6 +1047,7 @@ export default function AdminEmailsPage() {