17ca6399e0
New /feedback API domain backed by its own FEEDBACK_DB, mirroring the Calendar domain's router -> service -> DB pool layering: - Public endpoints (no auth): eligible-events listing, event config, submission with honeypot + rate limiting (in-memory + DB backstop). - Admin endpoints (session-header auth, reusing Calendar's users/sessions via a swappable feedback.auth.ts boundary): events/songs/questions CRUD, bulk reorder/assignment, aggregated reporting, CSV export. - Schema in sql/feedback/001_init.sql (8 tables), applied and verified against the real FEEDBACK_DB. - 64 Jest tests covering validation, auth, rate limiting, CSV escaping, and report aggregation (pure functions, no DB needed). Includes fixes from a security review: path traversal defense doesn't apply here (that's the frontend proxy, separate repo), but the rate-limiter cluster does - recordSubmission now counts every processed request (not just successful ones), the in-memory Map evicts empty entries instead of growing unbounded, FEEDBACK_IP_SALT is required at boot instead of silently degrading to unsalted hashing, and submission answer/rating arrays are capped and de-duplicated to bound insert amplification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
/**
|
|
* 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
|
|
});
|
|
}
|
|
});
|