diff --git a/src/Controller/DeviceImageController.php b/src/Controller/DeviceImageController.php index 0597b49..2dfdede 100644 --- a/src/Controller/DeviceImageController.php +++ b/src/Controller/DeviceImageController.php @@ -52,6 +52,9 @@ class DeviceImageController extends AbstractController return $device->getRotationIntervalMinutes() * 60 * 1000; } + /** Recent-claim window — see image() for the full semantics. */ + private const FRESH_CLAIM_WINDOW_SECONDS = 300; + #[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])] public function image(string $mac, Request $request, EntityManagerInterface $em): Response { @@ -61,6 +64,27 @@ class DeviceImageController extends AbstractController return new Response(null, Response::HTTP_NOT_FOUND); } + // The firmware sends X-Just-Provisioned: 1 on every poll until it + // sees X-Claimed: 1 in a response. The flag survives only inside the + // device's NVS — it gets set at WiFi-setup completion (after a BOOT-5s + // wipe or first-ever boot) and only ever clears once the server has + // affirmatively acknowledged the user has linked the device. + // + // Why we need this: NVS-wiping the device gives the firmware no way + // to tell the server "I just got reset." So if a frame is sold and + // the seller forgot to "Remove this frame" via the PWA, the buyer's + // first poll after WiFi setup would otherwise return 200 with the + // seller's image — leaking the seller's photos onto the buyer's + // panel until the buyer happens to navigate to /setup/{mac}. + // + // With this header, the server short-circuits to 204 (which the + // firmware paints as the setup QR) when the binding looks stale. + // Once the user re-links via /setup/{mac}, linkedAt becomes recent + // and we transition out of the gate. + $justProvisioned = $request->headers->get('X-Just-Provisioned') === '1'; + $linkedAtAge = (new \DateTimeImmutable())->getTimestamp() - $device->getLinkedAt()->getTimestamp(); + $bindingIsFresh = $linkedAtAge <= self::FRESH_CLAIM_WINDOW_SECONDS; + $intervalMs = $this->computeIntervalMs($device); $currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1'); $device->markSeen(); @@ -83,6 +107,29 @@ class DeviceImageController extends AbstractController // the second flush triggers a second publish to keep it accurate. $this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device)); + // The fresh-provisioning gate: refuse to serve images for stale + // bindings. Forces the device to display the setup QR (its 204 path) + // until the user re-links via /setup/{mac}. + if ($justProvisioned && !$bindingIsFresh) { + $this->logger->info('device.poll.awaiting_claim', [ + 'device_id' => $device->getId(), + 'mac' => $mac, + 'linked_age_sec' => $linkedAtAge, + ]); + $r = new Response(null, Response::HTTP_NO_CONTENT); + $r->headers->set('X-Interval-Ms', (string) $intervalMs); + return $r; + } + + // Past the gate: any response we build below should tell the device + // it's been acknowledged so it can clear its just-provisioned flag. + $stampClaimed = static function (Response $r) use ($justProvisioned, $bindingIsFresh): Response { + if ($justProvisioned && $bindingIsFresh) { + $r->headers->set('X-Claimed', '1'); + } + return $r; + }; + // Locked image bypasses rotation entirely. Otherwise, advance only // when the device's configured schedule says it's due — except a // cold-boot poll (X-Boot-Reason: cold) is treated as a deliberate @@ -108,7 +155,7 @@ class DeviceImageController extends AbstractController ]); $r = new Response(null, Response::HTTP_NO_CONTENT); $r->headers->set('X-Interval-Ms', (string) $intervalMs); - return $r; + return $stampClaimed($r); } // Asset lookup is needed before the 304 check so we can compare its @@ -131,7 +178,7 @@ class DeviceImageController extends AbstractController ]); $r = new Response(null, Response::HTTP_NO_CONTENT); $r->headers->set('X-Interval-Ms', (string) $intervalMs); - return $r; + return $stampClaimed($r); } // 304: device already has this image — skip the binary transfer and redraw. @@ -160,7 +207,7 @@ class DeviceImageController extends AbstractController $r = new Response(null, Response::HTTP_NOT_MODIFIED); $r->headers->set('X-Image-Id', (string) $image->getId()); $r->headers->set('X-Interval-Ms', (string) $intervalMs); - return $r; + return $stampClaimed($r); } $binPath = $this->projectDir . '/' . $asset->getFilePath(); @@ -173,7 +220,7 @@ class DeviceImageController extends AbstractController ]); $r = new Response(null, Response::HTTP_NO_CONTENT); $r->headers->set('X-Interval-Ms', (string) $intervalMs); - return $r; + return $stampClaimed($r); } // Record what the device is now showing, plus the orientation and @@ -215,6 +262,6 @@ class DeviceImageController extends AbstractController $response->headers->set('X-Image-Sha256', hash_file('sha256', $binPath)); $response->headers->set('X-Interval-Ms', (string) $intervalMs); - return $response; + return $stampClaimed($response); } } diff --git a/tests/Functional/Controller/DeviceImageControllerTest.php b/tests/Functional/Controller/DeviceImageControllerTest.php index b07867c..3231284 100644 --- a/tests/Functional/Controller/DeviceImageControllerTest.php +++ b/tests/Functional/Controller/DeviceImageControllerTest.php @@ -478,6 +478,63 @@ class DeviceImageControllerTest extends AppWebTestCase $this->assertResponseStatusCodeSame(304); } + // ── X-Just-Provisioned gate ────────────────────────────────────────── + // + // The device sets a "just provisioned" flag in NVS at WiFi-setup time + // and sends X-Just-Provisioned: 1 on every poll until it sees X-Claimed + // back. The server's contract: + // + // - Header set + binding is stale → 204 (force the device to its + // setup-QR screen instead of leaking the prior owner's photo). + // - Header set + binding is fresh → normal response + X-Claimed: 1 + // so the firmware clears the flag and resumes regular operation. + // - Header absent → no special behavior (legacy + steady-state polls). + + public function test_just_provisioned_with_stale_binding_returns_204(): void + { + $setup = $this->createTestSetup(); + $device = $setup['device']; + + // Backdate linkedAt to look stale (older than the FRESH_CLAIM window). + $ref = new \ReflectionProperty(\App\Entity\Device::class, 'linkedAt'); + $ref->setAccessible(true); + $ref->setValue($device, new \DateTimeImmutable('-1 hour')); + $this->em()->flush(); + + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Just_Provisioned' => '1', + ]); + + $this->assertResponseStatusCodeSame(204, 'stale binding + just-provisioned must NOT serve the prior owner\'s image'); + $this->assertFalse( + $this->client->getResponse()->headers->has('X-Claimed'), + 'X-Claimed must not be sent until the binding is fresh', + ); + } + + public function test_just_provisioned_with_fresh_binding_serves_with_X_Claimed(): void + { + $setup = $this->createTestSetup(); + // createTestSetup() doesn't backdate linkedAt — it's now-ish, well + // within the FRESH_CLAIM window — so this exercises the post-claim + // transition branch. + + $this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [ + 'HTTP_X_Just_Provisioned' => '1', + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('1', $this->client->getResponse()->headers->get('X-Claimed')); + } + + public function test_no_just_provisioned_header_means_no_X_Claimed_response_header(): void + { + $this->createTestSetup(); + $this->client->request('GET', '/api/device/' . self::MAC . '/image'); + $this->assertResponseIsSuccessful(); + $this->assertFalse($this->client->getResponse()->headers->has('X-Claimed')); + } + // Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path) public function test_returns_204_when_ready_asset_has_null_file_path(): void {