Harden auth, payments, and frontend against review findings.

Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes.

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