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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user