Files
vliaudatandClaude Opus 5 235af90aed chore: record how the panel reaches the server
Neither the reverse proxy nor a production Next server logs requests, so
"is the device on TLS?" was not answerable without a packet capture —
and the capture then produced nothing, because tcpdump buffers its
output and a handful of SYN lines never filled the buffer.

One line per wake, naming the scheme and saying so plainly when the
panel is being served in clear. At a two-hour refresh that is a dozen
lines a day, and it turns a question that cost an evening into a grep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 23:14:51 +02:00

98 lines
3.4 KiB
TypeScript

import { NextResponse } from 'next/server';
import { publicBaseUrl } from '@/lib/config';
import { clientIp, deviceHeader, deviceNumber } from '@/lib/device/headers';
import { checkTransport } from '@/lib/device/transport';
import { computeRefreshRate } from '@/lib/device/refresh';
import { authenticateDevice } from '@/lib/device/session';
import { prisma } from '@/lib/db';
import { rateLimit } from '@/lib/ratelimit';
import { buildCurrentScreen, renderAndStore } from '@/lib/screen/service';
export const dynamic = 'force-dynamic';
/**
* The only call that matters. The panel wakes, asks what to show, and goes
* back to sleep for `refresh_rate` seconds.
*
* `filename` is the hash of the image bytes: when it matches what the firmware
* already has, it skips the redraw. That is where the battery life comes from,
* so the renderer must stay byte-stable for unchanged content.
*/
export async function GET(request: Request) {
const transport = checkTransport(request);
if (!transport.ok) {
return transport.response;
}
const limit = rateLimit(`display:${clientIp(request)}`, 60, 60_000);
if (!limit.allowed) {
return NextResponse.json(
{ error: 'Trop de requêtes' },
{ status: 429, headers: { 'Retry-After': String(limit.retryAfter) } },
);
}
const device = await authenticateDevice(request);
if (!device) {
return NextResponse.json({ error: 'Jeton invalide' }, { status: 401 });
}
const now = new Date();
const baseUrl = publicBaseUrl(request);
// How the panel actually reaches us is not otherwise observable: neither the
// reverse proxy nor a production Next server logs requests, which is what
// made a plain "is it on TLS?" question take a packet capture to answer.
// One line per wake, and at most a handful a day.
const scheme = baseUrl.startsWith('https://') ? 'https' : 'http';
console.info(
`[device] ${device.friendlyId} via ${scheme} (${baseUrl})` +
(scheme === 'http' ? ' — en clair, voir DEVICE_ALLOW_HTTP' : ''),
);
const { payload, settings, status } = await buildCurrentScreen(now, baseUrl);
const image = await renderAndStore(payload, settings.imageFormat);
const refreshRate = computeRefreshRate({
now,
status,
timezone: settings.timezone,
openSec: settings.refreshRateOpenSec,
closedSec: settings.refreshRateClosedSec,
});
await prisma.device.update({
where: { id: device.id },
data: {
lastSeenAt: now,
fwVersion: deviceHeader(request, 'fw-version'),
batteryVoltage: deviceNumber(request, 'battery-voltage'),
percentCharged: roundOrNull(deviceNumber(request, 'percent-charged')),
rssi: roundOrNull(deviceNumber(request, 'rssi')),
lastFilename: image.filename,
lastRefreshRate: refreshRate,
},
});
return NextResponse.json(
{
image_url: `${baseUrl}/api/device/image/${image.filename}`,
filename: image.filename,
refresh_rate: refreshRate,
// Firmware updates are not this application's business: it drives a
// display, it does not manage the fleet.
update_firmware: false,
reset_firmware: false,
firmware_url: null,
firmware_version: null,
special_function: 'none',
image_url_timeout: 0,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}
function roundOrNull(value: number | null): number | null {
return value === null ? null : Math.round(value);
}