Completed
Push — master ( cb3971...27794e )
by Stefano
16s queued 16s
created

I18nMiddleware::updateSession()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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