server: generic 500s, fix duplicate/invalid-id error handling
All admin API error responses (and the global error handler) now emit
RFC 7807 application/problem+json bodies (type/title/status/detail)
instead of the ad hoc { error: string } shape, per review feedback on
this PR. Scoped to admin.ts + the global handler in index.ts, since
that's what this PR already touches; public.ts's routes still use the
old shape pending a follow-up.
This commit is contained in:
parent
da8a815677
commit
59eb54d9fb
4 changed files with 148 additions and 35 deletions
|
|
@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|||
import type { DB } from '../db.js';
|
||||
import { businessDay, formatLocal, parseDbTime } from '../time.js';
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { sendProblem } from '../problem-details.js';
|
||||
|
||||
const SESSION_COOKIE = 'wutz_admin';
|
||||
|
||||
|
|
@ -35,16 +36,29 @@ function safeEqual(a: string, b: string): boolean {
|
|||
|
||||
function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean {
|
||||
if (isAuthed(req)) return true;
|
||||
reply.code(401).send({ error: 'unauthorized' });
|
||||
sendProblem(req, reply, 401, 'Unauthorized', 'unauthorized');
|
||||
return false;
|
||||
}
|
||||
|
||||
// `Number(nonNumericString)` is NaN, and NaN binds as SQL NULL — an
|
||||
// `UPDATE ... WHERE id = NULL` silently matches zero rows instead of
|
||||
// throwing, so a bad :id param used to "succeed" the same way a genuinely
|
||||
// missing id does. Reject it explicitly instead.
|
||||
function parseId(raw: string, req: FastifyRequest, reply: FastifyReply): number | null {
|
||||
const id = Number(raw);
|
||||
if (!Number.isInteger(id)) {
|
||||
sendProblem(req, reply, 400, 'Bad Request', 'invalid id');
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
||||
app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => {
|
||||
const expected = process.env.ADMIN_PASSWORD;
|
||||
if (!expected) return reply.code(500).send({ error: 'ADMIN_PASSWORD not set' });
|
||||
if (!expected) return sendProblem(req, reply, 500, 'Internal Server Error', 'ADMIN_PASSWORD not set');
|
||||
if (!safeEqual(req.body?.password ?? '', expected)) {
|
||||
return reply.code(401).send({ error: 'wrong password' });
|
||||
return sendProblem(req, reply, 401, 'Unauthorized', 'wrong password');
|
||||
}
|
||||
const token = randomBytes(32).toString('hex');
|
||||
validSessions.add(token);
|
||||
|
|
@ -79,11 +93,18 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const { name, price_cents } = req.body ?? ({} as any);
|
||||
if (!name || !isValidCents(price_cents)) return reply.code(400).send({ error: 'invalid' });
|
||||
const info = db
|
||||
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
|
||||
.run(name, price_cents);
|
||||
return { id: Number(info.lastInsertRowid) };
|
||||
if (!name || !isValidCents(price_cents)) return sendProblem(req, reply, 400, 'Bad Request', 'invalid');
|
||||
try {
|
||||
const info = db
|
||||
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
|
||||
.run(name, price_cents);
|
||||
return { id: Number(info.lastInsertRowid) };
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE') && String(e.message).includes('drinks.name')) {
|
||||
return sendProblem(req, reply, 409, 'Conflict', 'name already exists');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -91,10 +112,11 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
'/admin/api/drinks/:id',
|
||||
async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const id = Number(req.params.id);
|
||||
const id = parseId(req.params.id, req, reply);
|
||||
if (id === null) return;
|
||||
const { name, price_cents, archived } = req.body ?? {};
|
||||
if (price_cents !== undefined && !isValidCents(price_cents)) {
|
||||
return reply.code(400).send({ error: 'invalid price_cents' });
|
||||
return sendProblem(req, reply, 400, 'Bad Request', 'invalid price_cents');
|
||||
}
|
||||
const sets: string[] = [];
|
||||
const args: any[] = [];
|
||||
|
|
@ -103,26 +125,33 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
if (archived !== undefined) { sets.push('archived = ?'); args.push(archived ? 1 : 0); }
|
||||
if (sets.length === 0) return { ok: true };
|
||||
args.push(id);
|
||||
db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
||||
const info = db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
||||
if (info.changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'drink not found');
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/admin/api/drinks/:id', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const id = Number(req.params.id);
|
||||
const id = parseId(req.params.id, req, reply);
|
||||
if (id === null) return;
|
||||
const sold = db
|
||||
.prepare('SELECT 1 FROM transaction_items WHERE drink_id = ? LIMIT 1')
|
||||
.get(id);
|
||||
if (sold) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: 'Getränk wurde bereits verkauft — bitte archivieren statt löschen.' });
|
||||
return sendProblem(
|
||||
req,
|
||||
reply,
|
||||
409,
|
||||
'Conflict',
|
||||
'Getränk wurde bereits verkauft — bitte archivieren statt löschen.'
|
||||
);
|
||||
}
|
||||
db.transaction(() => {
|
||||
const changes = db.transaction(() => {
|
||||
db.prepare('DELETE FROM bar_drinks WHERE drink_id = ?').run(id);
|
||||
db.prepare('DELETE FROM drinks WHERE id = ?').run(id);
|
||||
return db.prepare('DELETE FROM drinks WHERE id = ?').run(id).changes;
|
||||
})();
|
||||
if (changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'drink not found');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
|
@ -145,9 +174,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
if (!requireAuth(req, reply)) return;
|
||||
const name = req.body?.name?.trim();
|
||||
const pfand_cents = req.body?.pfand_cents ?? 0;
|
||||
if (!name) return reply.code(400).send({ error: 'name required' });
|
||||
if (!name) return sendProblem(req, reply, 400, 'Bad Request', 'name required');
|
||||
if (!isValidCents(pfand_cents)) {
|
||||
return reply.code(400).send({ error: 'invalid pfand_cents' });
|
||||
return sendProblem(req, reply, 400, 'Bad Request', 'invalid pfand_cents');
|
||||
}
|
||||
try {
|
||||
const info = db
|
||||
|
|
@ -156,7 +185,7 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
return { id: Number(info.lastInsertRowid) };
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE')) {
|
||||
return reply.code(409).send({ error: 'name already exists' });
|
||||
return sendProblem(req, reply, 409, 'Conflict', 'name already exists');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
|
@ -167,10 +196,16 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
'/admin/api/bars/:id',
|
||||
async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const id = Number(req.params.id);
|
||||
const id = parseId(req.params.id, req, reply);
|
||||
if (id === null) return;
|
||||
const { name, pfand_cents, drink_ids } = req.body ?? {};
|
||||
if (pfand_cents !== undefined && !isValidCents(pfand_cents)) {
|
||||
return reply.code(400).send({ error: 'invalid pfand_cents' });
|
||||
return sendProblem(req, reply, 400, 'Bad Request', 'invalid pfand_cents');
|
||||
}
|
||||
if (drink_ids !== undefined) {
|
||||
if (!Array.isArray(drink_ids) || !drink_ids.every(did => Number.isInteger(did))) {
|
||||
return sendProblem(req, reply, 400, 'Bad Request', 'invalid drink_ids');
|
||||
}
|
||||
}
|
||||
try {
|
||||
db.transaction(() => {
|
||||
|
|
@ -187,14 +222,18 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
const ins = db.prepare(
|
||||
'INSERT INTO bar_drinks (bar_id, drink_id, sort_order) VALUES (?, ?, ?)'
|
||||
);
|
||||
drink_ids.forEach((did, idx) => ins.run(id, did, idx));
|
||||
// Dedupe: a repeated id would otherwise trip bar_drinks' own
|
||||
// (bar_id, drink_id) primary key and get mis-reported below as
|
||||
// "name already exists" via the UNIQUE substring match.
|
||||
[...new Set(drink_ids)].forEach((did, idx) => ins.run(id, did, idx));
|
||||
}
|
||||
})();
|
||||
} catch (e: any) {
|
||||
if (String(e.message).includes('UNIQUE')) {
|
||||
return reply.code(409).send({ error: 'name already exists' });
|
||||
const msg = String(e.message);
|
||||
if (msg.includes('UNIQUE') && msg.includes('bars.name')) {
|
||||
return sendProblem(req, reply, 409, 'Conflict', 'name already exists');
|
||||
}
|
||||
if (e.message === 'name empty') return reply.code(400).send({ error: 'name empty' });
|
||||
if (msg === 'name empty') return sendProblem(req, reply, 400, 'Bad Request', 'name empty');
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
|
|
@ -203,19 +242,25 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
|||
|
||||
app.delete<{ Params: { id: string } }>('/admin/api/bars/:id', async (req, reply) => {
|
||||
if (!requireAuth(req, reply)) return;
|
||||
const id = Number(req.params.id);
|
||||
const id = parseId(req.params.id, req, reply);
|
||||
if (id === null) return;
|
||||
const used = db
|
||||
.prepare('SELECT 1 FROM transactions WHERE bar_id = ? LIMIT 1')
|
||||
.get(id);
|
||||
if (used) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ error: 'Tresen hat Transaktionen — Löschen würde die Statistik verfälschen.' });
|
||||
return sendProblem(
|
||||
req,
|
||||
reply,
|
||||
409,
|
||||
'Conflict',
|
||||
'Tresen hat Transaktionen — Löschen würde die Statistik verfälschen.'
|
||||
);
|
||||
}
|
||||
db.transaction(() => {
|
||||
const changes = db.transaction(() => {
|
||||
db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id);
|
||||
db.prepare('DELETE FROM bars WHERE id = ?').run(id);
|
||||
return db.prepare('DELETE FROM bars WHERE id = ?').run(id).changes;
|
||||
})();
|
||||
if (changes === 0) return sendProblem(req, reply, 404, 'Not Found', 'bar not found');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue