Add Feedback domain module: public submission flow, admin CRUD, reporting
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>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user