/** * Required External Modules and Interfaces */ import express, {Request, Response} from 'express'; import {Guid} from 'guid-typescript'; import logger from '../../../middleware/logger'; import {getEligibleEvents, getEventConfigBySlug} from './events.public.service'; import {submitFeedback} from './submissions.service'; import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit'; /** * Router Definition */ export const publicRouter = express.Router(); /** * True if the honeypot field was filled in — a real visitor never types * into it, since it's hidden with CSS only. Pulled out as a pure function * so the short-circuit behaviour is unit-testable without a live DB. */ export const isHoneypotTriggered = (body: any): boolean => { return typeof body?.website === 'string' && body.website.trim().length > 0; }; /** * @swagger * /feedback/events: * get: * summary: List currently eligible events * description: Returns events that are published, on or after their concert day, and before their feedback deadline. An empty array is a valid, expected response. * tags: * - feedback * responses: * 200: * description: Success * content: * application/json: * schema: * type: array * items: * $ref: '#/components/schemas/EventSummary' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/ProcessingError' */ publicRouter.get('/events', async (req: Request, res: Response) => { try { const events = await getEligibleEvents(); res.status(200).send(events); } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /feedback/events/{slug}: * get: * summary: Get the full public config for one event * description: Returns event meta, ordered setlist, and ordered active questions. 404 if the slug is unknown, 410 if the event exists but is outside its feedback window. * tags: * - feedback * parameters: * - in: path * name: slug * required: true * schema: * type: string * responses: * 200: * description: Success * content: * application/json: * schema: * $ref: '#/components/schemas/EventConfig' * 404: * description: Unknown slug * 410: * description: Event exists but feedback is closed * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/ProcessingError' */ publicRouter.get('/events/:slug', async (req: Request, res: Response) => { try { const result = await getEventConfigBySlug(req.params.slug); if (result.status === 'NOT_FOUND') { res.status(404).send({status: 'NOT_FOUND'}); return; } if (result.status === 'CLOSED') { res.status(410).send({status: 'FEEDBACK_CLOSED'}); return; } res.status(200).send(result.event); } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } }); /** * @swagger * /feedback/events/{slug}/submissions: * post: * summary: Submit feedback for an event * description: Every field is optional; the only validation error the public form can produce is EMPTY_SUBMISSION (nothing was filled in). Rate-limited per IP hash and honeypot-checked. * tags: * - feedback * parameters: * - in: path * name: slug * required: true * schema: * type: string * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/SubmissionRequest' * responses: * 201: * description: Submitted * content: * application/json: * schema: * $ref: '#/components/schemas/SubmissionResponse' * 400: * description: Nothing was filled in * 404: * description: Unknown slug * 410: * description: Event exists but feedback is closed * 429: * description: Rate limited * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/ProcessingError' */ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Response) => { try { const body = req.body || {}; // Honeypot: a real visitor never fills this in. Fake success, persist // nothing, stay silent about it having failed. if (isHoneypotTriggered(body)) { logger.info('Feedback honeypot triggered', {slug: req.params.slug}); res.status(201).send({submissionId: -1}); return; } const ipHash = hashIp(req.ip || ''); if (await isRateLimited(ipHash)) { res.status(429).send({status: 'RATE_LIMITED'}); return; } // Count every request that reaches this point against the limit, // regardless of outcome - an attacker sending EMPTY/NOT_FOUND/CLOSED // requests still costs DB round-trips per attempt and must not get an // unlimited number of free ones. recordSubmission(ipHash); const result = await submitFeedback(req.params.slug, body, ipHash); switch (result.status) { case 'NOT_FOUND': res.status(404).send({status: 'NOT_FOUND'}); return; case 'CLOSED': res.status(410).send({status: 'FEEDBACK_CLOSED'}); return; case 'EMPTY': res.status(400).send({status: 'EMPTY_SUBMISSION'}); return; case 'OK': res.status(201).send({submissionId: result.submissionId}); return; } } catch (e: any) { let errorGuid = Guid.create().toString(); logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); res.status(500).send({ 'status': 'PROCESSING_ERROR', 'message': 'Internal Server Error. Try again later.', 'reference': errorGuid }); } });