I18nMiddleware::getResponseWithCookie()   A
last analyzed

Complexity

Conditions 4
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 6
nc 2
nop 2
dl 0
loc 11
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2022 Atlas Srl, ChannelWeb Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
namespace BEdita\I18n\Middleware;
16
17
use BEdita\I18n\Core\I18nTrait;
18
use Cake\Core\Configure;
19
use Cake\Core\InstanceConfigTrait;
20
use Cake\Http\Cookie\Cookie;
21
use Cake\Http\Exception\BadRequestException;
22
use Cake\Http\Exception\InternalErrorException;
23
use Cake\Http\Response;
24
use Cake\Http\ServerRequest;
25
use Cake\I18n\DateTime;
26
use Cake\I18n\I18n;
27
use Cake\Utility\Hash;
28
use Cake\Validation\Validation;
29
use Laminas\Diactoros\Response\RedirectResponse;
30
use Locale;
31
use LogicException;
32
use Psr\Http\Message\ResponseInterface;
33
use Psr\Http\Message\ServerRequestInterface;
34
use Psr\Http\Server\MiddlewareInterface;
35
use Psr\Http\Server\RequestHandlerInterface;
36
37
/**
38
 * i18n middleware.
39
 *
40
 * It is responsible to setup the right locale based on URI path as `/:lang/page/to/reach`.
41
 * It is configurable to redirect URI matches some rules using `:lang` prefix.
42
 */
43
class I18nMiddleware implements MiddlewareInterface
44
{
45
    use I18nTrait;
46
    use InstanceConfigTrait;
47
48
    /**
49
     * Define when I18n rules are applied with `/:lang` prefix:
50
     *  - 'match': array of URL paths, if there's an exact match rule is applied
51
     *  - 'startWith': array of URL paths, if current URL path starts with one of these rule is applied
52
     *  - 'switchLangUrl': reserved URL (for example `/lang`) used to switch language and redirect to referer URL.
53
     *                     Disabled by default.
54
     *  - 'cookie': array for cookie that keeps the locale value. By default no cookie is used.
55
     *      - 'name': cookie name
56
     *      - 'create': set to `true` if the middleware is responsible of cookie creation
57
     *      - 'expire': used when `create` is `true` to define when the cookie must expire
58
     *  - 'sessionKey': the session key where store locale. The session is used as fallback to detect locale if cookie is disabled.
59
     *                  Set `null` if you don't want session.
60
     *
61
     * @var array
62
     */
63
    protected array $_defaultConfig = [
64
        'match' => [],
65
        'startWith' => [],
66
        'switchLangUrl' => null,
67
        'cookie' => [
68
            'name' => null,
69
            'create' => false,
70
            'expire' => '+1 year',
71
        ],
72
        'sessionKey' => null,
73
    ];
74
75
    /**
76
     * Middleware constructor.
77
     *
78
     * @param array $config Configuration.
79
     */
80
    public function __construct(array $config = [])
81
    {
82
        $this->setConfig($config);
83
    }
84
85
    /**
86
     * Setup used language code and locale from URL prefix `/:lang`
87
     *
88
     * Add `/:lang` (language code) prefix to a URL if a match is found
89
     * using `match` and `startWith` configurations.
90
     *
91
     * At the moment only primary languages are correctly handled as language codes to be used as URL prefix.
92
     *
93
     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
94
     * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
95
     * @return \Psr\Http\Message\ResponseInterface A response.
96
     */
97
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
98
    {
99
        $path = $request->getUri()->getPath();
100
        if ($path !== '/') {
101
            $path = rtrim($path, '/'); // remove trailing slashes
102
        }
103
104
        if ($path === (string)$this->getConfig('switchLangUrl')) {
105
            return $this->changeLangAndRedirect($request);
106
        }
107
108
        $redir = false;
109
        foreach ($this->getConfig('startWith') as $needle) {
110
            if (stripos($path, $needle) === 0) {
111
                $redir = true;
112
            }
113
        }
114
115
        $locale = $this->detectLocale($request);
116
117
        if (!$redir && !in_array($path, $this->getConfig('match'))) {
118
            $this->setupLocale($locale);
119
            $this->updateSession($request, $this->getLocale());
120
            $response = $handler->handle($request);
121
122
            return $this->getResponseWithCookie($response, $this->getLocale());
123
        }
124
125
        $lang = $this->getDefaultLang();
126
        if ($locale) {
127
            $localeLang = Hash::get($this->getLocales(), $locale);
128
            if ($localeLang) {
129
                $lang = $localeLang;
130
            } else {
131
                // try with primary language
132
                $primary = Locale::getPrimaryLanguage($locale);
133
                if (Hash::get($this->getLanguages(), $primary)) {
134
                    $lang = $primary;
135
                }
136
            }
137
        }
138
139
        $uri = $request->getUri()->withPath(sprintf('%s%s', $lang, rtrim($path, '/')));
140
141
        return new RedirectResponse($uri);
142
    }
143
144
    /**
145
     * Detect locale following the rules:
146
     *
147
     * 1. first try to detect from url path
148
     * 2. then try to detect from cookie
149
     * 3. finally try to detect it from HTTP Accept-Language header
150
     *
151
     * @param \Cake\Http\ServerRequest $request The request.
152
     * @return string
153
     */
154
    protected function detectLocale(ServerRequest $request): string
155
    {
156
        $path = $request->getUri()->getPath();
157
        $urlLang = (string)Hash::get(explode('/', $path), '1');
158
        $locale = array_search($urlLang, $this->getLocales());
159
        if ($locale !== false) {
160
            return (string)$locale;
161
        }
162
163
        $locale = (string)$request->getCookie($this->getConfig('cookie.name', ''));
164
        if (!empty($locale)) {
165
            return $locale;
166
        }
167
168
        $locale = $this->readSession($request);
169
        if (!empty($locale)) {
170
            return $locale;
171
        }
172
173
        return (string)Locale::acceptFromHttp($request->getHeaderLine('Accept-Language'));
174
    }
175
176
    /**
177
     * Setup locale and language code from passed `$locale`.
178
     * If `$locale` is not found in configuraion then use the default.
179
     *
180
     * @param string|null $locale Detected HTTP locale.
181
     * @return void
182
     */
183
    protected function setupLocale(?string $locale): void
184
    {
185
        $locales = $this->getLocales();
186
        $lang = (string)Hash::get($locales, (string)$locale);
187
        if (empty($lang)) {
188
            $lang = $this->getDefaultLang();
189
            $locale = array_search($lang, $locales);
190
        }
191
192
        if (empty($lang) || $locale === false) {
193
            throw new InternalErrorException(
194
                'Something was wrong with I18n configuration. Check "I18n.locales" and "I18n.default"'
195
            );
196
        }
197
198
        Configure::write('I18n.lang', $lang);
199
        I18n::setLocale($locale);
200
    }
201
202
    /**
203
     * Return a response object with the locale cookie set or updated.
204
     *
205
     * The cookie is added only if the middleware is configured to create cookie.
206
     *
207
     * @param \Psr\Http\Message\ResponseInterface $response The response.
208
     * @param string $locale The locale string to set in cookie.
209
     * @return \Psr\Http\Message\ResponseInterface
210
     */
211
    protected function getResponseWithCookie(ResponseInterface $response, string $locale): ResponseInterface
212
    {
213
        $name = $this->getConfig('cookie.name');
214
        $create = $this->getConfig('cookie.create', false);
215
        if ($create !== true || empty($name) || !$response instanceof Response) {
216
            return $response;
217
        }
218
219
        $expire = new DateTime($this->getConfig('cookie.expire', '+1 year'));
220
221
        return $response->withCookie(new Cookie($name, $locale, $expire));
222
    }
223
224
    /**
225
     * Change lang and redirect to referer.
226
     * If no referer found, redirect to "/".
227
     *
228
     * Require query string `new` and `redirect`
229
     *
230
     * @param \Cake\Http\ServerRequest $request The request
231
     * @return \Psr\Http\Message\ResponseInterface
232
     * @throws \Cake\Http\Exception\BadRequestException When missing required query string or unsupported language
233
     */
234
    protected function changeLangAndRedirect(ServerRequest $request): ResponseInterface
235
    {
236
        if (!$this->getConfig('cookie.name') && !$this->getSessionKey()) {
237
            throw new LogicException(
238
                'I18nMiddleware misconfigured. `switchLangUrl` requires `cookie.name` or `sessionKey`'
239
            );
240
        }
241
242
        $new = (string)$request->getQuery('new');
243
        if (empty($new)) {
244
            throw new BadRequestException('Missing required "new" query string');
245
        }
246
247
        $locale = array_search($new, $this->getLocales());
248
        if ($locale === false) {
249
            throw new BadRequestException(sprintf('Lang "%s" not supported', $new));
250
        }
251
252
        $redirect = (string)$request->getQuery('redirect', $request->referer(false));
253
        if (!empty($redirect) && strpos($redirect, '/') !== 0 && !Validation::url($redirect, true)) {
254
            throw new BadRequestException('"redirect" query string not valid');
255
        }
256
        $redirect = !empty($redirect) ? $redirect : '/';
257
258
        $this->updateSession($request, $locale);
259
260
        $response = (new Response())
261
            ->withLocation($redirect)
262
            ->withDisabledCache()
263
            ->withStatus(302);
264
265
        return $this->getResponseWithCookie($response, $locale);
266
    }
267
268
    /**
269
     * Read locale from session.
270
     *
271
     * @param \Cake\Http\ServerRequest $request The request
272
     * @return string|null
273
     */
274
    protected function readSession(ServerRequest $request): ?string
275
    {
276
        $sessionKey = $this->getSessionKey();
277
        if ($sessionKey === null) {
278
            return null;
279
        }
280
281
        return $request->getSession()->read($sessionKey);
282
    }
283
284
    /**
285
     * Update session with locale.
286
     *
287
     * @param \Cake\Http\ServerRequest $request The request.
288
     * @param string $locale The locale string
289
     * @return void
290
     */
291
    protected function updateSession(ServerRequest $request, string $locale): void
292
    {
293
        $sessionKey = $this->getSessionKey();
294
        if ($sessionKey === null) {
295
            return;
296
        }
297
298
        $request->getSession()->write($sessionKey, $locale);
299
    }
300
301
    /**
302
     * Get the session key used to store locale.
303
     *
304
     * @return string|null
305
     */
306
    protected function getSessionKey(): ?string
307
    {
308
        $sessionKey = $this->getConfig('sessionKey');
309
        if (empty($sessionKey) || !is_string($sessionKey)) {
310
            return null;
311
        }
312
313
        return $sessionKey;
314
    }
315
}
316