Allow dev CORS from LAN IPs; add submission deletion
The dev-only CORS bypass in app.ts only ever matched http://localhost:<port>, never the LAN IP a phone actually connects through over WiFi - so testing the feedback form from a real device against a local dev API had its submissions silently rejected by CORS. Extended the bypass to also allow private LAN ranges (192.168.x.x, 10.x.x.x, 172.16-31.x.x), dev-only as before. Also adds DELETE /feedback/admin/submissions/:submissionId (cascades to the submission's answers, guest book entry, and newsletter signup in explicit dependency order, single-path by submission_id) so an admin can remove an individual abusive/inappropriate entry - decided in IMPLEMENTATION_PLAN.md §7 item 8. getGuestBookEntries now also returns submissionId so the admin UI can target the delete call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,15 +40,20 @@ let allowedHosts = [
|
|||||||
];
|
];
|
||||||
const isDev = process.env.NODE_ENV !== 'production';
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
const localhostRegex = /^http:\/\/localhost:\d+$/;
|
const localhostRegex = /^http:\/\/localhost:\d+$/;
|
||||||
|
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
|
||||||
|
// be reached from a real phone over WiFi during dev (the phone's Origin is
|
||||||
|
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
|
||||||
|
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
|
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
|
||||||
origin: function (origin: any, callback: any) {
|
origin: function (origin: any, callback: any) {
|
||||||
// Allow requests with no origin
|
// Allow requests with no origin
|
||||||
if (!origin) return callback(null, true);
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
// Any localhost port is fine outside production - dev servers pick
|
// Any localhost port, or a private-LAN IP, is fine outside production -
|
||||||
// whatever port is free (Next.js falls back from 3000 if it's taken).
|
// dev servers pick whatever port is free (Next.js falls back from 3000
|
||||||
if (isDev && localhostRegex.test(origin)) {
|
// if it's taken), and real-device testing hits the dev machine by IP.
|
||||||
|
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
* Required External Modules and Interfaces
|
* Required External Modules and Interfaces
|
||||||
*/
|
*/
|
||||||
import express, {Request, Response} from 'express';
|
import express, {Request, Response} from 'express';
|
||||||
|
import {Guid} from 'guid-typescript';
|
||||||
|
import logger from '../../../middleware/logger';
|
||||||
import {requireAdminAuth} from '../feedback.auth';
|
import {requireAdminAuth} from '../feedback.auth';
|
||||||
import {eventsAdminRouter} from './events.admin.router';
|
import {eventsAdminRouter} from './events.admin.router';
|
||||||
import {songsAdminRouter} from './songs.admin.router';
|
import {songsAdminRouter} from './songs.admin.router';
|
||||||
import {questionsAdminRouter} from './questions.admin.router';
|
import {questionsAdminRouter} from './questions.admin.router';
|
||||||
import {reportsAdminRouter} from './reports.admin.router';
|
import {reportsAdminRouter} from './reports.admin.router';
|
||||||
|
import * as ReportsAdminService from './reports.admin.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Definition
|
* Router Definition
|
||||||
@@ -46,6 +49,48 @@ adminRouter.get('/me', (req: Request, res: Response) => {
|
|||||||
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /feedback/admin/submissions/{submissionId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete a single submission
|
||||||
|
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
|
||||||
|
* tags: [feedback-admin]
|
||||||
|
* parameters:
|
||||||
|
* - $ref: '#/components/parameters/SessionIdHeader'
|
||||||
|
* - $ref: '#/components/parameters/SessionKeyHeader'
|
||||||
|
* - in: path
|
||||||
|
* name: submissionId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 204:
|
||||||
|
* description: Deleted
|
||||||
|
* 404:
|
||||||
|
* description: Unknown submission
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const deleted = await ReportsAdminService.deleteSubmission(Number(req.params.submissionId));
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).send({status: 'NOT_FOUND'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(204).send();
|
||||||
|
} 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
adminRouter.use('/events', eventsAdminRouter);
|
adminRouter.use('/events', eventsAdminRouter);
|
||||||
adminRouter.use('/events', reportsAdminRouter);
|
adminRouter.use('/events', reportsAdminRouter);
|
||||||
adminRouter.use('/songs', songsAdminRouter);
|
adminRouter.use('/songs', songsAdminRouter);
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export const getReport = async (eventId: number): Promise<EventReport | null> =>
|
|||||||
|
|
||||||
export interface GuestBookEntry {
|
export interface GuestBookEntry {
|
||||||
entryId: number;
|
entryId: number;
|
||||||
|
submissionId: number;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
@@ -170,12 +171,18 @@ export const getGuestBookEntries = async (eventId: number, page: number, pageSiz
|
|||||||
try {
|
try {
|
||||||
const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
|
const totalRows = await conn.query('SELECT COUNT(*) as cnt FROM guest_book_entries WHERE event_id = ?', [eventId]);
|
||||||
const rows = await conn.query(
|
const rows = await conn.query(
|
||||||
'SELECT entry_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
|
'SELECT entry_id, submission_id, created_at, display_name, message FROM guest_book_entries WHERE event_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
|
||||||
[eventId, pageSize, (page - 1) * pageSize]
|
[eventId, pageSize, (page - 1) * pageSize]
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
total: Number(totalRows[0].cnt),
|
total: Number(totalRows[0].cnt),
|
||||||
entries: rows.map((r: any) => ({entryId: r.entry_id, submittedAt: r.created_at, displayName: r.display_name, message: r.message}))
|
entries: rows.map((r: any) => ({
|
||||||
|
entryId: r.entry_id,
|
||||||
|
submissionId: r.submission_id,
|
||||||
|
submittedAt: r.created_at,
|
||||||
|
displayName: r.display_name,
|
||||||
|
message: r.message
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await conn.end();
|
await conn.end();
|
||||||
@@ -192,6 +199,39 @@ export interface NewsletterSignupRow {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one submission and everything under it (its answers, guest book
|
||||||
|
* entry, newsletter signup). Single-path deletes by submission_id - unlike
|
||||||
|
* deleteEvent's multi-path cascade issue, there's only one way to reach each
|
||||||
|
* child table here, so explicit ordering is for consistency with that
|
||||||
|
* function's style, not to work around an ambiguous-cascade error.
|
||||||
|
*/
|
||||||
|
export const deleteSubmission = async (submissionId: number): Promise<boolean> => {
|
||||||
|
let conn = await NachklangFeedbackDB.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const rows = await conn.query('SELECT submission_id FROM submissions WHERE submission_id = ?', [submissionId]);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await conn.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.query('DELETE FROM guest_book_entries WHERE submission_id = ?', [submissionId]);
|
||||||
|
await conn.query('DELETE FROM newsletter_signups WHERE submission_id = ?', [submissionId]);
|
||||||
|
await conn.query('DELETE FROM submission_answers WHERE submission_id = ?', [submissionId]);
|
||||||
|
await conn.query('DELETE FROM submissions WHERE submission_id = ?', [submissionId]);
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await conn.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getNewsletterSignups = async (eventId: number): Promise<NewsletterSignupRow[]> => {
|
export const getNewsletterSignups = async (eventId: number): Promise<NewsletterSignupRow[]> => {
|
||||||
let conn = await NachklangFeedbackDB.getConnection();
|
let conn = await NachklangFeedbackDB.getConnection();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user