From ae676aa86c0c2c4f8d47970f6764b93b07cf6ecc Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 21:06:38 +0200 Subject: [PATCH] Fix empty-body JSON error on body-less admin requests The shared cloud req() helper set content-type: application/json on every request, so body-less DELETE/revoke/rotate calls tripped Fastify's "Body cannot be empty" 400. Only send the header when there's a body, and (defensively) make the server parse an empty application/json body as undefined instead of erroring. Co-Authored-By: Claude Opus 4.8 --- server/src/index.ts | 10 ++++++++++ src/lib/cloud/campaigns.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server/src/index.ts b/server/src/index.ts index d108839..439365b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -38,6 +38,16 @@ export function buildServer() { // visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so // req.ip is the real client. Only Traefik can reach the container, so this is safe. const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true }); + // Tolerate a body-less request that still sets content-type: application/json + // (browsers/fetch wrappers often do this for DELETE/POST-with-no-body). The + // default parser 400s on an empty JSON body; every handler treats a missing + // body as {}, so parse empty → undefined instead. + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + const s = (body as string).trim(); + if (!s) return done(null, undefined); + try { done(null, JSON.parse(s)); } + catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); } + }); const hub = new RoomHub(); const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS); void accounts.load(); diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts index 92d284f..d5e9015 100644 --- a/src/lib/cloud/campaigns.ts +++ b/src/lib/cloud/campaigns.ts @@ -16,7 +16,11 @@ export interface CloudCharInfo { id: string; name: string; ownerUserId: string; export async function req(path: string, init?: RequestInit): Promise { let res: Response; try { - res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } }); + // Only declare a JSON content-type when we actually send a body — Fastify + // rejects an empty body that claims content-type: application/json, which + // broke the body-less DELETE/revoke/rotate calls. + const jsonType = init?.body != null ? { 'content-type': 'application/json' } : {}; + res = await fetch(`${base()}${path}`, { ...init, headers: { ...jsonType, ...authHeaders(), ...(init?.headers ?? {}) } }); } catch { throw new CloudError('Could not reach the server.'); }