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