The e-ink firmware carries a certificate-authority bundle fixed when it was built, so it cannot validate a chain rooted in an authority created afterwards. Let's Encrypt's ISRG Root YR was issued in May 2026 and is not even in an up-to-date Ubuntu CA bundle yet; the kit's firmware predates it. The handshake fails before a request is ever sent, which is why neither Traefik nor the application saw anything at all while the device reported "API connection cannot be established". Ruled out first, with evidence: TLS 1.2 and the ECDHE-RSA-AES-GCM suites an ESP32 needs are both offered, and the intermediate is not cross-signed by an older root, so no alternate path exists in what is served. A Traefik router now serves four device paths over :80, ahead of the entrypoint-wide redirect. The administration stays on TLS. The device token travels in clear; it is used for nothing else and is revocable from the settings page, and the image URL is an unguessable content hash. DEVICE_ALLOW_HTTP existed but was never read — a setting that does nothing misrepresents what it protects. The device routes now refuse an unencrypted request unless it is set, so opening this door is a written decision rather than the silent consequence of a proxy change. DEPLOY.md records the whole diagnosis, including the commands that distinguish a TLS failure from an application one, and what to do the day the firmware learns the new roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
88 lines
2.9 KiB
TypeScript
88 lines
2.9 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);
|
|
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);
|
|
}
|