Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.26% covered (success)
97.26%
71 / 73
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PanelLocale
97.26% covered (success)
97.26%
71 / 73
75.00% covered (warning)
75.00%
6 / 8
30
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
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 cookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 translator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 negotiate
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 fallback
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 fromBrowser
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 acceptedLanguages
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Middleware;
6
7use Celema\Verba\Translator;
8use Celema\Verba\Verba;
9use Cosray\Config;
10use Cosray\Locales;
11use Cosray\User;
12use Override;
13use Psr\Http\Message\ResponseInterface as Response;
14use Psr\Http\Message\ServerRequestInterface as Request;
15use Psr\Http\Server\MiddlewareInterface as Middleware;
16use Psr\Http\Server\RequestHandlerInterface as Handler;
17
18/**
19 * Negotiates the panel UI language independently of the content locale:
20 * the user's stored preference, then the language chosen on the sign-in
21 * pages, then config `panel.locale`, then the browser's Accept-Language,
22 * then English. Activates a translator for the chosen locale with the
23 * remaining panel locales as fallback chain and restores the content
24 * translator afterwards. The content locale attributes stay untouched, so
25 * editing and serialization are unaffected.
26 */
27class PanelLocale implements Middleware
28{
29    public function __construct(
30        protected Config $config,
31    ) {}
32
33    #[Override]
34    public function process(Request $request, Handler $handler): Response
35    {
36        $locales = $request->getAttribute('locales', null);
37
38        if (!$locales instanceof Locales) {
39            return $handler->handle($request);
40        }
41
42        $available = $locales->panelLocales();
43
44        if ($available === []) {
45            return $handler->handle($request);
46        }
47
48        $id = $this->negotiate($request, $available);
49        $translator = $this->translator($locales, $id);
50        $previous = Verba::translator();
51        Verba::activate($translator);
52
53        try {
54            return $handler->handle(
55                $request
56                    ->withAttribute('panelLocale', $id)
57                    ->withAttribute('panelLocales', $available)
58                    ->withAttribute('translator', $translator),
59            );
60        } finally {
61            if ($previous !== null) {
62                Verba::activate($previous);
63            } else {
64                Verba::deactivate();
65            }
66        }
67    }
68
69    /** The cookie holding the language chosen on the sign-in pages. */
70    public static function cookie(Config $config): string
71    {
72        return $config->app->name . '_panel_locale';
73    }
74
75    /**
76     * A translator for one of the panel locales, with the fallbacks the
77     * panel uses, like for a mail in the language a user chose.
78     */
79    public function translator(Locales $locales, string $id): Translator
80    {
81        return new Translator($id, $locales->catalogs(), $this->fallback($id, $locales->panelLocales()));
82    }
83
84    /** @param non-empty-list<string> $available */
85    protected function negotiate(Request $request, array $available): string
86    {
87        $user = $request->getAttribute('user', null);
88
89        if (
90            $user instanceof User
91            && $user->panelLocale !== null
92            && in_array($user->panelLocale, $available, true)
93        ) {
94            return $user->panelLocale;
95        }
96
97        $chosen = $request->getCookieParams()[self::cookie($this->config)] ?? null;
98
99        if (is_string($chosen) && in_array($chosen, $available, true)) {
100            return $chosen;
101        }
102
103        $configured = $this->config->panel->locale;
104
105        if ($configured !== null && in_array($configured, $available, true)) {
106            return $configured;
107        }
108
109        return (
110            $this->fromBrowser($request, $available)
111                ?? (in_array('en', $available, true) ? 'en' : $available[0])
112        );
113    }
114
115    /**
116     * The locales tried per string when the negotiated one lacks a
117     * translation: config default first, then English, then the rest.
118     *
119     * @param list<string> $available
120     * @return list<string>
121     */
122    protected function fallback(string $id, array $available): array
123    {
124        $chain = array_unique([$this->config->panel->locale ?? 'en', 'en', ...$available]);
125
126        return array_values(array_filter(
127            $chain,
128            static fn(string $locale) => $locale !== $id && in_array($locale, $available, true),
129        ));
130    }
131
132    /** @param non-empty-list<string> $available */
133    protected function fromBrowser(Request $request, array $available): ?string
134    {
135        $lookup = [];
136
137        foreach ($available as $id) {
138            $lookup[strtolower(str_replace('_', '-', $id))] = $id;
139        }
140
141        foreach ($this->acceptedLanguages($request) as $tag) {
142            $tag = strtolower($tag);
143            $primary = explode('-', $tag)[0];
144
145            if (isset($lookup[$tag])) {
146                return $lookup[$tag];
147            }
148
149            if (isset($lookup[$primary])) {
150                return $lookup[$primary];
151            }
152        }
153
154        return null;
155    }
156
157    /** @return list<string> Language tags in descending quality order. */
158    protected function acceptedLanguages(Request $request): array
159    {
160        $accepted = [];
161        $position = 0;
162
163        foreach (explode(',', $request->getHeaderLine('Accept-Language')) as $part) {
164            $params = explode(';', trim($part));
165            $tag = trim($params[0]);
166
167            if (preg_match('/^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/', $tag) !== 1) {
168                continue;
169            }
170
171            $quality = 1.0;
172
173            foreach (array_slice($params, 1) as $param) {
174                if (preg_match('/^\s*q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/', $param, $m) !== 1) {
175                    continue;
176                }
177
178                $quality = (float) $m[1];
179            }
180
181            if ($quality > 0) {
182                $accepted[] = ['tag' => $tag, 'quality' => $quality, 'position' => $position];
183            }
184
185            $position++;
186        }
187
188        usort(
189            $accepted,
190            static fn(array $a, array $b) => $b['quality'] <=> $a['quality'] ?: $a['position'] <=> $b['position'],
191        );
192
193        return array_column($accepted, 'tag');
194    }
195}