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:
@@ -5,7 +5,7 @@ import { uniqueSlug } from '../lib/slugify.js';
|
||||
|
||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||
console.log(`Database type: ${dbType}`);
|
||||
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
|
||||
// Do not log DATABASE_URL: it may contain credentials.
|
||||
|
||||
async function migrate() {
|
||||
console.log('Running migrations...');
|
||||
@@ -43,6 +43,9 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Magic link tokens table
|
||||
await (db as any).run(sql`
|
||||
@@ -541,6 +544,9 @@ async function migrate() {
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
try {
|
||||
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
|
||||
} catch (e) { /* column may already exist */ }
|
||||
|
||||
// Magic link tokens table
|
||||
await (db as any).execute(sql`
|
||||
@@ -974,6 +980,27 @@ async function migrate() {
|
||||
`);
|
||||
}
|
||||
|
||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
||||
const indexStatements = [
|
||||
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
|
||||
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
|
||||
];
|
||||
for (const stmt of indexStatements) {
|
||||
try {
|
||||
if (dbType === 'sqlite') {
|
||||
await (db as any).run(sql.raw(stmt));
|
||||
} else {
|
||||
await (db as any).execute(sql.raw(stmt));
|
||||
}
|
||||
} catch (e) { /* index may already exist */ }
|
||||
}
|
||||
|
||||
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
||||
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
||||
|
||||
@@ -18,6 +18,8 @@ export const sqliteUsers = sqliteTable('users', {
|
||||
googleId: text('google_id'),
|
||||
rucNumber: text('ruc_number'),
|
||||
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
|
||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||
tokenVersion: integer('token_version').notNull().default(0),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
});
|
||||
@@ -359,6 +361,8 @@ export const pgUsers = pgTable('users', {
|
||||
googleId: varchar('google_id', { length: 255 }),
|
||||
rucNumber: varchar('ruc_number', { length: 15 }),
|
||||
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
|
||||
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
|
||||
tokenVersion: pgInteger('token_version').notNull().default(0),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
});
|
||||
|
||||
+40
-8
@@ -56,6 +56,19 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// Baseline security headers on every response.
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
app.use('*', async (c, next) => {
|
||||
await next();
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
c.header('X-XSS-Protection', '0');
|
||||
if (isProduction) {
|
||||
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
});
|
||||
|
||||
// OpenAPI specification
|
||||
const openApiSpec = {
|
||||
openapi: '3.0.0',
|
||||
@@ -1827,15 +1840,34 @@ const openApiSpec = {
|
||||
},
|
||||
};
|
||||
|
||||
// OpenAPI JSON endpoint
|
||||
app.get('/openapi.json', (c) => {
|
||||
return c.json(openApiSpec);
|
||||
// API documentation is disabled in production to avoid exposing the full API
|
||||
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
|
||||
if (!isProduction) {
|
||||
// OpenAPI JSON endpoint
|
||||
app.get('/openapi.json', (c) => {
|
||||
return c.json(openApiSpec);
|
||||
});
|
||||
|
||||
// Swagger UI
|
||||
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
|
||||
} else {
|
||||
app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404));
|
||||
app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404));
|
||||
}
|
||||
|
||||
// Static file serving for uploads.
|
||||
// Uploads are validated as images at write time, but as defense-in-depth we force
|
||||
// any non-image path to download as an opaque attachment so a stray/legacy
|
||||
// .html/.svg can never be rendered (and therefore never execute script) in-origin.
|
||||
app.use('/uploads/*', async (c, next) => {
|
||||
await next();
|
||||
const path = c.req.path.toLowerCase();
|
||||
const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path);
|
||||
if (!isInlineImage) {
|
||||
c.header('Content-Disposition', 'attachment');
|
||||
c.header('Content-Type', 'application/octet-stream');
|
||||
}
|
||||
});
|
||||
|
||||
// Swagger UI
|
||||
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
|
||||
|
||||
// Static file serving for uploads
|
||||
app.use('/uploads/*', serveStatic({ root: './' }));
|
||||
|
||||
// Health check
|
||||
|
||||
+92
-13
@@ -4,10 +4,21 @@ import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { Context } from 'hono';
|
||||
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
|
||||
import { eq, and, gt } from 'drizzle-orm';
|
||||
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
|
||||
import { generateId, getNow, toDbDate } from './utils.js';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
|
||||
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
|
||||
const rawJwtSecret = process.env.JWT_SECRET;
|
||||
|
||||
// Never allow the insecure default in production: forgeable tokens = full account takeover.
|
||||
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
|
||||
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
|
||||
}
|
||||
if (!rawJwtSecret) {
|
||||
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
|
||||
}
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
|
||||
const JWT_ISSUER = 'spanglish';
|
||||
const JWT_AUDIENCE = 'spanglish-app';
|
||||
|
||||
@@ -15,6 +26,7 @@ export interface JWTPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
tokenVersion?: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
@@ -84,23 +96,36 @@ export async function verifyMagicLinkToken(
|
||||
)
|
||||
);
|
||||
|
||||
// Use a single generic error for all invalid states to avoid leaking token state
|
||||
const genericError = 'Invalid or expired token';
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (tokenRecord.usedAt) {
|
||||
return { valid: false, error: 'Token already used' };
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
if (new Date(tokenRecord.expiresAt) < new Date()) {
|
||||
return { valid: false, error: 'Token expired' };
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
await (db as any)
|
||||
// Atomically consume the token: only the request that flips used_at from NULL wins.
|
||||
// This prevents a double-spend race where two concurrent requests both pass the
|
||||
// read-time "not used" check above.
|
||||
const result: any = await (db as any)
|
||||
.update(magicLinkTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
|
||||
.where(and(
|
||||
eq((magicLinkTokens as any).id, tokenRecord.id),
|
||||
isNull((magicLinkTokens as any).usedAt)
|
||||
));
|
||||
|
||||
const affected = result?.changes ?? result?.rowCount ?? 0;
|
||||
if (affected === 0) {
|
||||
return { valid: false, error: genericError };
|
||||
}
|
||||
|
||||
return { valid: true, userId: tokenRecord.userId };
|
||||
}
|
||||
@@ -175,18 +200,26 @@ export function validatePassword(password: string): { valid: boolean; error?: st
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function createToken(userId: string, email: string, role: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, email, role })
|
||||
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setIssuer(JWT_ISSUER)
|
||||
.setAudience(JWT_AUDIENCE)
|
||||
.setExpirationTime('7d')
|
||||
.setExpirationTime('1d')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
|
||||
export async function bumpTokenVersion(userId: string): Promise<void> {
|
||||
await (db as any)
|
||||
.update(users)
|
||||
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
|
||||
.where(eq((users as any).id, userId));
|
||||
}
|
||||
|
||||
export async function createRefreshToken(userId: string): Promise<string> {
|
||||
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
@@ -223,10 +256,44 @@ export async function getAuthUser(c: Context): Promise<any | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Never load the password hash into request context — it is only needed for
|
||||
// explicit password-verification routes that query it separately.
|
||||
const user = await dbGet<any>(
|
||||
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
|
||||
(db as any)
|
||||
.select({
|
||||
id: (users as any).id,
|
||||
email: (users as any).email,
|
||||
name: (users as any).name,
|
||||
phone: (users as any).phone,
|
||||
role: (users as any).role,
|
||||
languagePreference: (users as any).languagePreference,
|
||||
isClaimed: (users as any).isClaimed,
|
||||
googleId: (users as any).googleId,
|
||||
rucNumber: (users as any).rucNumber,
|
||||
accountStatus: (users as any).accountStatus,
|
||||
tokenVersion: (users as any).tokenVersion,
|
||||
createdAt: (users as any).createdAt,
|
||||
updatedAt: (users as any).updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq((users as any).id, payload.sub))
|
||||
);
|
||||
return user || null;
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject tokens issued before a logout-everywhere / password change
|
||||
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Suspended/unclaimed accounts must not retain API access via an old JWT
|
||||
if (user.accountStatus && user.accountStatus !== 'active') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireAuth(roles?: string[]) {
|
||||
@@ -252,3 +319,15 @@ export async function isFirstUser(): Promise<boolean> {
|
||||
);
|
||||
return !result || result.length === 0;
|
||||
}
|
||||
|
||||
/** Fetch only the password hash column (never expose via getAuthUser). */
|
||||
export async function getUserPasswordHash(userId: string): Promise<string | null> {
|
||||
const row = await dbGet<any>(
|
||||
(db as any)
|
||||
.select({ password: (users as any).password })
|
||||
.from(users)
|
||||
.where(eq((users as any).id, userId))
|
||||
);
|
||||
const hash = row?.password;
|
||||
return hash && String(hash).length > 0 ? String(hash) : null;
|
||||
}
|
||||
|
||||
@@ -247,10 +247,20 @@ async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResu
|
||||
/**
|
||||
* Main send function that routes to the appropriate provider
|
||||
*/
|
||||
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = String(email).split('@');
|
||||
if (!domain) return '***';
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}***@${domain}`;
|
||||
}
|
||||
|
||||
async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
||||
const provider = getEmailProvider();
|
||||
|
||||
console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`);
|
||||
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
|
||||
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
|
||||
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
|
||||
|
||||
switch (provider) {
|
||||
case 'resend':
|
||||
@@ -478,7 +488,7 @@ export const emailService = {
|
||||
|
||||
// Replace variables
|
||||
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
||||
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
||||
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
||||
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
||||
|
||||
@@ -1292,7 +1302,14 @@ export const emailService = {
|
||||
eventId?: string;
|
||||
sentBy?: string | null;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
|
||||
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
|
||||
|
||||
// Strip CR/LF from header-bound values to prevent email header injection
|
||||
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
|
||||
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
|
||||
const to = stripHeader(rawTo) as string;
|
||||
const subject = stripHeader(rawSubject) as string;
|
||||
const replyTo = stripHeader(rawReplyTo);
|
||||
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
|
||||
@@ -1216,8 +1216,26 @@ Spanglish`,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to replace template variables
|
||||
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
|
||||
// Escape HTML-significant characters so substituted variable values can't inject markup.
|
||||
function escapeHtmlValue(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Helper function to replace template variables.
|
||||
// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are
|
||||
// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event
|
||||
// location, attendee name). The template markup itself is never escaped. Subjects and
|
||||
// plain-text bodies pass `escapeHtml=false` so they don't show literal entities.
|
||||
export function replaceTemplateVariables(
|
||||
template: string,
|
||||
variables: Record<string, any>,
|
||||
escapeHtml: boolean = false
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
// Handle conditional blocks {{#if variable}}...{{/if}}
|
||||
@@ -1229,16 +1247,20 @@ export function replaceTemplateVariables(template: string, variables: Record<str
|
||||
// Replace simple variables {{variable}}
|
||||
const variableRegex = /\{\{(\w+)\}\}/g;
|
||||
result = result.replace(variableRegex, (match, varName) => {
|
||||
return variables[varName] !== undefined ? String(variables[varName]) : match;
|
||||
if (variables[varName] === undefined) return match;
|
||||
const raw = String(variables[varName]);
|
||||
return escapeHtml ? escapeHtmlValue(raw) : raw;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to wrap content in the base template
|
||||
// Helper to wrap content in the base template.
|
||||
// `content` is treated as trusted HTML (it is the already-rendered body). The wrapper's
|
||||
// own variables (subject, year, etc.) are HTML-escaped on substitution.
|
||||
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
|
||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
|
||||
return replaceTemplateVariables(wrappedContent, variables);
|
||||
const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content);
|
||||
return replaceTemplateVariables(wrappedContent, variables, true);
|
||||
}
|
||||
|
||||
// Get all available variables for a template by slug
|
||||
|
||||
@@ -80,11 +80,8 @@ export async function createInvoice(params: CreateInvoiceParams): Promise<LNbits
|
||||
}
|
||||
|
||||
console.log('Creating LNbits invoice:', {
|
||||
url: `${config.url}${apiEndpoint}`,
|
||||
amount: params.amount,
|
||||
unit: payload.unit,
|
||||
memo: params.memo,
|
||||
webhook: params.webhookUrl,
|
||||
});
|
||||
|
||||
const response = await fetch(`${config.url}${apiEndpoint}`, {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
/**
|
||||
* Simple in-memory rate limiter.
|
||||
*
|
||||
* Suitable for a single backend instance (the current deployment model). If the
|
||||
* backend is ever scaled horizontally, replace the in-memory Map with a shared
|
||||
* store (e.g. Redis) so limits are enforced across instances.
|
||||
*/
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
// Periodically drop expired buckets so the Map does not grow unbounded.
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (now > bucket.resetAt) buckets.delete(key);
|
||||
}
|
||||
}, 60_000);
|
||||
// Don't keep the process alive just for cleanup.
|
||||
(cleanup as any).unref?.();
|
||||
|
||||
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
|
||||
export function getClientIp(c: Context): string {
|
||||
const forwarded = c.req.header('x-forwarded-for');
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return c.req.header('x-real-ip') || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one unit against a key. Returns whether the request is allowed and,
|
||||
* when blocked, how many seconds until the window resets.
|
||||
*/
|
||||
export function consumeRateLimit(
|
||||
key: string,
|
||||
max: number,
|
||||
windowMs: number
|
||||
): { allowed: boolean; retryAfter?: number } {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key);
|
||||
|
||||
if (!bucket || now > bucket.resetAt) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
bucket.count++;
|
||||
if (bucket.count > max) {
|
||||
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono middleware factory that rate-limits by client IP.
|
||||
* Use a distinct `prefix` per endpoint group so unrelated routes don't share a bucket.
|
||||
*/
|
||||
export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) {
|
||||
return async (c: Context, next: () => Promise<void>) => {
|
||||
const ip = getClientIp(c);
|
||||
const result = consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
|
||||
if (!result.allowed) {
|
||||
return c.json(
|
||||
{ error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter },
|
||||
429
|
||||
);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
+66
-76
@@ -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
@@ -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' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+35
-12
@@ -1,28 +1,51 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
// Backend origin for API/upload proxying. Configurable per environment instead of
|
||||
// being hardcoded to localhost.
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
|
||||
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
|
||||
.split(',')
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)
|
||||
.map((hostname) => ({ protocol: 'https', hostname }));
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'X-XSS-Protection', value: '0' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
|
||||
];
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ['localhost', 'images.unsplash.com'],
|
||||
// Restrict remote image sources to a known allowlist instead of allowing any
|
||||
// https host (which let the Next image optimizer be used as an open proxy).
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '3001',
|
||||
},
|
||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||
{ protocol: 'http', hostname: 'localhost', port: '3001' },
|
||||
...extraImageHosts,
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://localhost:3001/api/:path*',
|
||||
destination: `${BACKEND_URL}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: 'http://localhost:3001/uploads/:path*',
|
||||
destination: `${BACKEND_URL}/uploads/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
|
||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -155,8 +156,12 @@ export default function BookingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to external booking if enabled
|
||||
if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) {
|
||||
// Redirect to external booking if enabled (only https:// targets are allowed)
|
||||
if (
|
||||
eventRes.event.externalBookingEnabled &&
|
||||
eventRes.event.externalBookingUrl &&
|
||||
isSafeExternalUrl(eventRes.event.externalBookingUrl)
|
||||
) {
|
||||
window.location.href = eventRes.event.externalBookingUrl;
|
||||
return;
|
||||
}
|
||||
@@ -473,6 +478,16 @@ export default function BookingPage() {
|
||||
paymentMethod: formData.paymentMethod,
|
||||
ticketCount,
|
||||
});
|
||||
// Fetch full payment credentials now that we hold a ticket capability token
|
||||
try {
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(
|
||||
params.eventId as string,
|
||||
primaryTicket.id
|
||||
);
|
||||
setPaymentConfig(paymentOptions);
|
||||
} catch {
|
||||
// Keep the flags-only config from initial load if the gated fetch fails
|
||||
}
|
||||
setStep('manual_payment');
|
||||
} else {
|
||||
// Cash payment - go straight to success
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function BookingPaymentPage() {
|
||||
|
||||
// Get payment config for the event
|
||||
if (ticketData.eventId) {
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
|
||||
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id);
|
||||
setPaymentConfig(paymentOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import GoogleSignInButton from '@/components/GoogleSignInButton';
|
||||
import { authApi } from '@/lib/api';
|
||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
function LoginContent() {
|
||||
@@ -25,8 +26,8 @@ function LoginContent() {
|
||||
password: '',
|
||||
});
|
||||
|
||||
// Check for redirect after login
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||
// Check for redirect after login (only same-origin relative paths are honoured)
|
||||
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1047,6 +1047,7 @@ export default function AdminEmailsPage() {
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
sandbox=""
|
||||
className="w-full h-full min-h-[500px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
@@ -1100,6 +1101,7 @@ export default function AdminEmailsPage() {
|
||||
{selectedLog.bodyHtml ? (
|
||||
<iframe
|
||||
srcDoc={selectedLog.bodyHtml}
|
||||
sandbox=""
|
||||
className="w-full h-full min-h-[400px]"
|
||||
title="Email Content"
|
||||
/>
|
||||
|
||||
@@ -2217,7 +2217,7 @@ export default function AdminEventDetailPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe srcDoc={previewHtml} className="w-full h-full min-h-[500px]" title="Email Preview" />
|
||||
<iframe srcDoc={previewHtml} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
// Constant-time string comparison to avoid leaking the secret via response timing.
|
||||
function secretsMatch(provided: unknown, expected: string): boolean {
|
||||
if (typeof provided !== 'string' || provided.length === 0) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { secret, tag } = body;
|
||||
|
||||
// Validate the revalidation secret
|
||||
// Validate the revalidation secret. Reject if it is unset or left at an insecure default.
|
||||
const revalidateSecret = process.env.REVALIDATE_SECRET;
|
||||
if (!revalidateSecret || secret !== revalidateSecret) {
|
||||
if (!revalidateSecret || revalidateSecret === 'change-me' || revalidateSecret.length < 16) {
|
||||
return NextResponse.json({ error: 'Revalidation is not configured' }, { status: 503 });
|
||||
}
|
||||
if (!secretsMatch(secret, revalidateSecret)) {
|
||||
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { safeInternalPath } from '@/lib/safeRedirect';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
declare global {
|
||||
@@ -82,10 +83,9 @@ export default function GoogleSignInButton({
|
||||
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
|
||||
onSuccess?.();
|
||||
|
||||
// Use window.location for navigation to ensure clean state
|
||||
if (redirectTo) {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
// Use window.location for navigation to ensure clean state.
|
||||
// Constrain to a same-origin path to avoid open redirects.
|
||||
window.location.href = safeInternalPath(redirectTo, '/dashboard');
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
|
||||
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
|
||||
|
||||
@@ -21,6 +21,17 @@ function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string
|
||||
return match ? match[1].trim() : updatedAt;
|
||||
}
|
||||
|
||||
// Only permit safe link schemes. Anything else (javascript:, data:, etc.) is dropped
|
||||
// so a malicious markdown link can't execute script when clicked.
|
||||
function sanitizeHref(href?: string): string | undefined {
|
||||
if (!href) return undefined;
|
||||
const trimmed = href.trim();
|
||||
// Allow relative/anchor/protocol-relative-safe links
|
||||
if (trimmed.startsWith('/') || trimmed.startsWith('#')) return trimmed;
|
||||
if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return trimmed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function LegalPageLayout({
|
||||
slug,
|
||||
initialLocale,
|
||||
@@ -141,17 +152,20 @@ export default function LegalPageLayout({
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
// Style links
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Style links (with scheme allowlist to block javascript:/data: URLs)
|
||||
a: ({ href, children }) => {
|
||||
const safeHref = sanitizeHref(href);
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
|
||||
target={safeHref?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={safeHref?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Style horizontal rules
|
||||
hr: () => (
|
||||
<hr className="my-8 border-gray-200" />
|
||||
|
||||
@@ -14,11 +14,18 @@ interface RichTextEditorProps {
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
// Escape HTML-significant characters so any raw HTML embedded in the markdown source
|
||||
// is neutralised before we layer our own generated tags on top (defense-in-depth;
|
||||
// the public renderer escapes too, and TipTap sanitizes via its schema).
|
||||
function escapeRawHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Convert markdown to HTML for TipTap
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '<p></p>';
|
||||
|
||||
let html = markdown;
|
||||
let html = escapeRawHtml(markdown);
|
||||
|
||||
// Convert horizontal rules first (before other processing)
|
||||
html = html.replace(/^---+$/gm, '<hr>');
|
||||
|
||||
@@ -44,6 +44,17 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = 'spanglish-token';
|
||||
const USER_KEY = 'spanglish-user';
|
||||
const AUTH_COOKIE = 'spanglish-auth';
|
||||
|
||||
function setAuthCookie() {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
function clearAuthCookie() {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -66,12 +77,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
setAuthCookie();
|
||||
} else if (res.status === 401) {
|
||||
// Token is invalid, clear auth state
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
clearAuthCookie();
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error, keep using cached data
|
||||
@@ -85,10 +98,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const savedUser = localStorage.getItem(USER_KEY);
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
// Refresh user data from server to get latest role/permissions
|
||||
refreshUser().finally(() => setIsLoading(false));
|
||||
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
|
||||
let parsedUser: User | null = null;
|
||||
try {
|
||||
parsedUser = JSON.parse(savedUser);
|
||||
} catch {
|
||||
parsedUser = null;
|
||||
}
|
||||
|
||||
if (parsedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(parsedUser);
|
||||
// Refresh user data from server to get latest role/permissions (source of truth)
|
||||
refreshUser().finally(() => setIsLoading(false));
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -99,6 +126,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(data.user);
|
||||
localStorage.setItem(TOKEN_KEY, data.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
setAuthCookie();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
@@ -166,10 +194,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
|
||||
const currentToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (currentToken) {
|
||||
fetch(`${API_BASE}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${currentToken}` },
|
||||
}).catch(() => {
|
||||
// Ignore network errors; local state is cleared regardless.
|
||||
});
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
clearAuthCookie();
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((updatedUser: User) => {
|
||||
|
||||
@@ -314,10 +314,11 @@ export const paymentOptionsApi = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Event-specific options (merged with global)
|
||||
getForEvent: (eventId: string) =>
|
||||
// Event-specific options (merged with global). Pass ticketId after booking to
|
||||
// retrieve bank/TPago credentials gated behind the booking capability token.
|
||||
getForEvent: (eventId: string, ticketId?: string) =>
|
||||
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
|
||||
`/api/payment-options/event/${eventId}`
|
||||
`/api/payment-options/event/${eventId}${ticketId ? `?ticketId=${encodeURIComponent(ticketId)}` : ''}`
|
||||
),
|
||||
|
||||
// Event overrides (admin only)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Returns a safe internal redirect path, or the provided fallback.
|
||||
*
|
||||
* Only same-origin relative paths are allowed (must start with a single "/" and not
|
||||
* "//", which the browser treats as protocol-relative -> external). This prevents
|
||||
* open-redirect attacks via a `?redirect=` parameter.
|
||||
*/
|
||||
export function safeInternalPath(value: string | null | undefined, fallback: string = '/'): string {
|
||||
if (!value) return fallback;
|
||||
// Must be a relative path rooted at "/", but not "//" or "/\" (protocol-relative).
|
||||
if (!value.startsWith('/')) return fallback;
|
||||
if (value.startsWith('//') || value.startsWith('/\\')) return fallback;
|
||||
// Reject attempts to smuggle a scheme or control characters.
|
||||
if (/[\x00-\x1f]/.test(value) || value.includes('\\')) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a URL is safe to use as an external navigation target:
|
||||
* an absolute https:// URL, or a same-origin relative path.
|
||||
*/
|
||||
export function isSafeExternalUrl(value: string | null | undefined): boolean {
|
||||
if (!value) return false;
|
||||
if (value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\')) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Defense-in-depth guard for authenticated areas.
|
||||
*
|
||||
* Auth tokens live in localStorage (not readable here), so we rely on a lightweight
|
||||
* `spanglish-auth` cookie set alongside login. The API remains the authoritative gate;
|
||||
* this only keeps unauthenticated visitors from loading the admin/dashboard JS shell.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (pathname.startsWith('/admin') || pathname.startsWith('/dashboard')) {
|
||||
const hasAuthCookie = request.cookies.get('spanglish-auth')?.value === '1';
|
||||
if (!hasAuthCookie) {
|
||||
const loginUrl = new URL('/login', request.url);
|
||||
loginUrl.searchParams.set('redirect', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*', '/dashboard/:path*'],
|
||||
};
|
||||
Reference in New Issue
Block a user