Files
vliaudatandClaude Opus 5 767c6b9d77 fix: serve the device paths with or without a trailing slash
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 22:43:39 +02:00

84 lines
2.8 KiB
TypeScript

import { NextResponse } from 'next/server';
import { authenticateDevice } from '@/lib/device/session';
import { clientIp, deviceHeader } from '@/lib/device/headers';
import { checkTransport } from '@/lib/device/transport';
import { prisma } from '@/lib/db';
import { rateLimit } from '@/lib/ratelimit';
export const dynamic = 'force-dynamic';
/** Never let a firmware in a retry loop fill the table in one request. */
const MAX_ENTRIES_PER_CALL = 50;
type IncomingLog = {
message?: unknown;
level?: unknown;
created_at?: unknown;
};
/**
* Firmware-side logs. Answered with 204 whatever happens to the contents: a
* device that cannot file a log must not conclude the server is down and
* start retrying, and these records are diagnostics, not data.
*/
export async function POST(request: Request) {
const transport = checkTransport(request);
if (!transport.ok) {
return transport.response;
}
const limit = rateLimit(`log:${clientIp(request)}`, 30, 60_000);
if (!limit.allowed) {
return new NextResponse(null, { status: 429 });
}
const device = await authenticateDevice(request);
if (!device) {
// 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[] = [];
try {
const body: unknown = await request.json();
const logs = (body as { logs?: unknown } | null)?.logs;
if (Array.isArray(logs)) {
entries = logs.slice(0, MAX_ENTRIES_PER_CALL) as IncomingLog[];
}
} catch {
// A malformed body is a diagnostic in itself; 204 keeps the device calm.
return new NextResponse(null, { status: 204 });
}
if (entries.length > 0) {
await prisma.deviceLog.createMany({
data: entries.map((entry) => ({
deviceId: device.id,
level: levelOf(entry.level),
message: String(entry.message ?? '').slice(0, 2000) || '(vide)',
payload: entry as object,
})),
});
}
return new NextResponse(null, { status: 204 });
}
function levelOf(value: unknown): 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' {
const level = String(value ?? '').toUpperCase();
return level === 'DEBUG' || level === 'WARN' || level === 'ERROR' ? level : 'INFO';
}