Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.88% covered (success)
96.88%
31 / 32
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Headers
96.88% covered (success)
96.88%
31 / 32
80.00% covered (warning)
80.00%
4 / 5
19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 vary
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 cacheControl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
6
 allowedOrigin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api;
6
7use Override;
8use Psr\Http\Message\ResponseInterface as Response;
9use Psr\Http\Message\ServerRequestInterface as Request;
10use Psr\Http\Server\MiddlewareInterface as Middleware;
11use Psr\Http\Server\RequestHandlerInterface as Handler;
12
13/**
14 * CORS for the configured origins and caching rules: anonymous reads may be
15 * kept by shared caches for `api.max_age` seconds, anything sent with a
16 * token never is. The API reads no cookies, so no credentials are allowed.
17 */
18final class Headers implements Middleware
19{
20    private const string METHODS = 'GET, POST, PUT, PATCH, DELETE';
21    private const string ALLOWED = 'Authorization, Content-Type, If-Match, If-None-Match';
22    private const string EXPOSED = 'ETag, Location, Retry-After';
23
24    /** @param list<string> $origins */
25    public function __construct(
26        private readonly Responses $responses,
27        private readonly array $origins,
28        private readonly int $maxAge,
29    ) {}
30
31    #[Override]
32    public function process(Request $request, Handler $handler): Response
33    {
34        $origin = $this->allowedOrigin($request->getHeaderLine('Origin'));
35
36        if ($request->getMethod() === 'OPTIONS' && $request->hasHeader('Access-Control-Request-Method')) {
37            $response = $this->responses->empty();
38
39            return $this->vary(
40                $origin === null
41                    ? $response
42                    : $response
43                        ->withHeader('Access-Control-Allow-Origin', $origin)
44                        ->withHeader('Access-Control-Allow-Methods', self::METHODS)
45                        ->withHeader('Access-Control-Allow-Headers', self::ALLOWED)
46                        ->withHeader('Access-Control-Max-Age', '86400'),
47            );
48        }
49
50        $response = $handler->handle($request);
51
52        if ($origin !== null) {
53            $response = $response
54                ->withHeader('Access-Control-Allow-Origin', $origin)
55                ->withHeader('Access-Control-Expose-Headers', self::EXPOSED);
56        }
57
58        return $this
59            ->vary($response)
60            ->withHeader('Cache-Control', $this->cacheControl($request, $response))
61            ->withAddedHeader('Vary', 'Authorization');
62    }
63
64    /**
65     * With listed origins the CORS headers depend on the request's Origin,
66     * also when it sends none, so a shared cache has to keep the answers apart.
67     */
68    private function vary(Response $response): Response
69    {
70        return $this->origins === [] || in_array('*', $this->origins, true)
71            ? $response
72            : $response->withAddedHeader('Vary', 'Origin');
73    }
74
75    private function cacheControl(Request $request, Response $response): string
76    {
77        $read = $request->getMethod() === 'GET' && $response->getStatusCode() < 300;
78
79        if (!$read || $request->hasHeader('Authorization') || isset($request->getQueryParams()['preview'])) {
80            return 'private, no-store';
81        }
82
83        return $this->maxAge > 0 ? "public, max-age={$this->maxAge}" : 'no-cache';
84    }
85
86    /** Any origin gets `*`, even a request without one, so every answer is the same. */
87    private function allowedOrigin(string $origin): ?string
88    {
89        if (in_array('*', $this->origins, true)) {
90            return '*';
91        }
92
93        return $origin !== '' && in_array($origin, $this->origins, true) ? $origin : null;
94    }
95}