From 767c6b9d77f934c8ecda342b2f3684880e5a85d0 Mon Sep 17 00:00:00 2001 From: vl Date: Mon, 21 Sep 2026 22:43:39 +0200 Subject: [PATCH] fix: serve the device paths with or without a trailing slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's HTTP client does not follow redirects. It asks for /api/setup/ with a trailing slash, Next answered 308 to normalise it, and the firmware reported "returned code is not OK. Code - 308" and gave up. Never having obtained a token, it then called /api/display with an empty one, got 401, and told the user it could not reach the API. Not TLS, not the network, not the port — a slash. Two earlier fixes were aimed at hypotheses the evidence did not support: a certificate chain the firmware genuinely cannot validate, and a port the shop's network turned out not to block. Both were reasoned from silence, because neither Traefik nor a production Next server logs requests by default. The answer came from a packet capture, and from the device's own words. /api/log now accepts a report from a panel that cannot authenticate. Refusing it with a 401 threw away the one diagnostic that mattered: the firmware was saying exactly what was wrong and we were discarding the message. Nothing is stored — the rows would reference a device that does not exist — but it reaches the server log, and the route was already rate-limited. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/api/log/route.ts | 17 +++++++++++++++-- next.config.ts | 22 ++++++++++++++++++++++ tests/integration/device-api.test.ts | 14 +++++++++++--- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/app/api/log/route.ts b/app/api/log/route.ts index 262918e..813197d 100644 --- a/app/api/log/route.ts +++ b/app/api/log/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { authenticateDevice } from '@/lib/device/session'; -import { clientIp } from '@/lib/device/headers'; +import { clientIp, deviceHeader } from '@/lib/device/headers'; import { checkTransport } from '@/lib/device/transport'; import { prisma } from '@/lib/db'; import { rateLimit } from '@/lib/ratelimit'; @@ -34,8 +34,21 @@ export async function POST(request: Request) { } const device = await authenticateDevice(request); + if (!device) { - return NextResponse.json({ error: 'Jeton invalide' }, { status: 401 }); + // A panel that cannot authenticate is precisely the panel whose own + // account of the failure is worth having. Refusing it with a 401 threw + // away the one diagnostic that mattered here: the firmware was reporting + // a 308 on /api/setup/, and we discarded the message saying so. + // + // Nothing is stored — the rows are tied to a device that does not exist — + // but it reaches the server log, where `docker compose logs app` will show + // it. The route is rate-limited above, so this is not an open write. + const body = await request.text().catch(() => ''); + console.warn( + `[device] journal d'un appareil non authentifié (ID: ${deviceHeader(request, 'id') ?? 'absent'}) : ${body.slice(0, 1000)}`, + ); + return new NextResponse(null, { status: 204 }); } let entries: IncomingLog[] = []; diff --git a/next.config.ts b/next.config.ts index 845c54c..913669a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,28 @@ const nextConfig: NextConfig = { // Next writes its own AGENTS.md/CLAUDE.md otherwise; this project documents // itself in README.md and PLAN.md. agentRules: false, + + // The panel's HTTP client does not follow redirects: it reports any non-2xx + // as "returned code is not OK" and gives up. The firmware asks for + // /api/setup/ with a trailing slash, which Next would answer with a 308 — + // so the device never obtained a token, then failed /api/display with an + // empty one, and reported that it could not reach the API at all. + // + // Serving both spellings is the fix. The redirect is disabled and the + // slashed paths are rewritten, rather than the device being asked to behave + // differently: it cannot. + skipTrailingSlashRedirect: true, + async rewrites() { + return [ + { source: '/api/setup/', destination: '/api/setup' }, + { source: '/api/display/', destination: '/api/display' }, + { source: '/api/log/', destination: '/api/log' }, + { source: '/api/health/', destination: '/api/health' }, + // Admin pages keep working when someone types the slash by hand, which + // Next would otherwise have redirected for us. + { source: '/admin/', destination: '/admin' }, + ]; + }, // These must be required from node_modules at runtime, not bundled. // satori loads harfbuzz and yoga as WebAssembly by relative path; bundling // rewrites that path and the renderer dies with a missing hb.wasm. resvg is diff --git a/tests/integration/device-api.test.ts b/tests/integration/device-api.test.ts index a87b521..b5d2c35 100644 --- a/tests/integration/device-api.test.ts +++ b/tests/integration/device-api.test.ts @@ -217,11 +217,19 @@ describe.skipIf(!hasDatabase)('device API', () => { }); describe('POST /api/log', () => { - it('refuses a request with no token', async () => { + it('accepts a log from a device that cannot authenticate, without storing it', async () => { + // A panel that cannot authenticate is precisely the panel whose account + // of the failure is worth having: this is how a 308 on /api/setup/ was + // finally diagnosed, after a 401 had been discarding the evidence. const response = await log( - deviceRequest('/api/log', {}, { method: 'POST', body: '{"logs":[]}' }), + deviceRequest( + '/api/log', + { ID: MAC }, + { method: 'POST', body: '{"logs":[{"message":"returned code is not OK. Code - 308"}]}' }, + ), ); - expect(response.status).toBe(401); + expect(response.status).toBe(204); + expect(await prisma.deviceLog.count()).toBe(0); }); it('stores what the firmware reports', async () => {