Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
FileController
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
2 / 2
4
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 show
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Shared\Infrastructure\Controllers;
6
7use App\Shared\Infrastructure\Storage\ImageStorage;
8use OpenApi\Attributes as OA;
9use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
10use Symfony\Component\HttpFoundation\BinaryFileResponse;
11use Symfony\Component\HttpFoundation\JsonResponse;
12use Symfony\Component\HttpFoundation\Request;
13use Symfony\Component\HttpFoundation\Response;
14use Symfony\Component\HttpFoundation\ResponseHeaderBag;
15use Symfony\Component\HttpFoundation\UriSigner;
16use Symfony\Component\Routing\Attribute\Route;
17
18/**
19 * Serves a stored image to an <img> tag. The route is public because the browser
20 * cannot attach the in-memory JWT to an image request; the signed, expiring URL
21 * minted by SignedImageUrl is the credential, and it is checked here.
22 *
23 * ponytail: PHP streams the bytes. Switch to nginx X-Accel-Redirect if image
24 * traffic ever competes with API traffic for FPM workers.
25 */
26#[OA\Tag(name: 'Files')]
27class FileController extends AbstractController
28{
29    /** Extension => served Content-Type; the route requirement limits it to these. */
30    private const CONTENT_TYPES = [
31        'jpg' => 'image/jpeg',
32        'jpeg' => 'image/jpeg',
33        'png' => 'image/png',
34        'webp' => 'image/webp',
35    ];
36
37    public function __construct(
38        private readonly ImageStorage $storage,
39        private readonly UriSigner $signer,
40    ) {
41    }
42
43    #[Route(
44        '/files/{name}',
45        name: 'files_show',
46        requirements: ['name' => '[a-f0-9]{32}\.(?:jpe?g|png|webp)'],
47        methods: ['GET'],
48    )]
49    public function show(string $name, Request $request): Response
50    {
51        if (!$this->signer->checkRequest($request)) {
52            return new JsonResponse(['error' => 'Invalid or expired image link.'], 403);
53        }
54
55        $path = $this->storage->absolutePath($name);
56        if (!is_file($path)) {
57            return new JsonResponse(['error' => 'File not found.'], 404);
58        }
59
60        $response = new BinaryFileResponse($path);
61        $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $name);
62        // The route requirement already limited the extension to the map above;
63        // the assertion only narrows the type for static analysis.
64        $extension = pathinfo($name, PATHINFO_EXTENSION);
65        assert(isset(self::CONTENT_TYPES[$extension]));
66        $response->headers->set('Content-Type', self::CONTENT_TYPES[$extension]);
67        $response->headers->set('X-Content-Type-Options', 'nosniff');
68        // Private: the URL is a capability, so a shared cache must not keep the bytes.
69        $response->setPrivate();
70        $response->setMaxAge(3600);
71
72        return $response;
73    }
74}