Passed
Push — develop ( da9b52...522b0e )
by nguereza
02:54
created

MaintenanceMiddleware::hasValidBypassCookie()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
eloc 13
c 2
b 0
f 0
nc 7
nop 2
dl 0
loc 19
rs 8.8333
1
<?php
2
3
/**
4
 * Platine Framework
5
 *
6
 * Platine Framework is a lightweight, high-performance, simple and elegant PHP
7
 * Web framework
8
 *
9
 * This content is released under the MIT License (MIT)
10
 *
11
 * Copyright (c) 2020 Platine Framework
12
 *
13
 * Permission is hereby granted, free of charge, to any person obtaining a copy
14
 * of this software and associated documentation files (the "Software"), to deal
15
 * in the Software without restriction, including without limitation the rights
16
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
 * copies of the Software, and to permit persons to whom the Software is
18
 * furnished to do so, subject to the following conditions:
19
 *
20
 * The above copyright notice and this permission notice shall be included in all
21
 * copies or substantial portions of the Software.
22
 *
23
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
 * SOFTWARE.
30
 */
31
32
/**
33
 *  @file MaintenanceMiddleware.php
34
 *
35
 *  The Maintenance middleware class is used to check application maintenance and
36
 * handle the request if needed
37
 *
38
 *  @package    Platine\Framework\Http\Middleware
39
 *  @author Platine Developers Team
40
 *  @copyright  Copyright (c) 2020
41
 *  @license    http://opensource.org/licenses/MIT  MIT License
42
 *  @link   https://www.platine-php.com
43
 *  @version 1.0.0
44
 *  @filesource
45
 */
46
47
declare(strict_types=1);
48
49
namespace Platine\Framework\Http\Middleware;
50
51
use Platine\Config\Config;
52
use Platine\Cookie\Cookie;
53
use Platine\Cookie\CookieManager;
54
use Platine\Framework\App\Application;
55
use Platine\Framework\Http\Exception\HttpException;
56
use Platine\Framework\Http\RequestData;
57
use Platine\Framework\Http\Response\RedirectResponse;
58
use Platine\Framework\Http\Response\TemplateResponse;
59
use Platine\Framework\Http\RouteHelper;
60
use Platine\Http\Handler\MiddlewareInterface;
61
use Platine\Http\Handler\RequestHandlerInterface;
62
use Platine\Http\ResponseInterface;
63
use Platine\Http\ServerRequestInterface;
64
use Platine\Stdlib\Helper\Json;
65
use Platine\Stdlib\Helper\Str;
66
use Platine\Template\Template;
67
use Throwable;
68
69
/**
70
 * @class MaintenanceMiddleware
71
 * @package Platine\Framework\Http\Middleware
72
 * @template T
73
 */
74
class MaintenanceMiddleware implements MiddlewareInterface
75
{
76
    /**
77
     * The configuration instance
78
     * @var Config<T>
79
     */
80
    protected Config $config;
81
82
    /**
83
     * The Application instance
84
     * @var Application
85
     */
86
    protected Application $app;
87
88
    /**
89
     * Create new instance
90
     * @param Config<T> $config
91
     * @param Application $app
92
     */
93
    public function __construct(
94
        Config $config,
95
        Application $app
96
    ) {
97
        $this->config = $config;
98
        $this->app = $app;
99
    }
100
101
    /**
102
     * {@inheritdoc}
103
     */
104
    public function process(
105
        ServerRequestInterface $request,
106
        RequestHandlerInterface $handler
107
    ): ResponseInterface {
108
109
        if ($this->isExceptUrl($request)) {
110
            return $handler->handle($request);
111
        }
112
113
        if ($this->app->isInMaintenance()) {
114
            try {
115
                $data = $this->app->maintenance()->data();
116
            } catch (Throwable $ex) {
117
                throw $ex;
118
            }
119
120
            if (isset($data['secret']) && trim($request->getUri()->getPath(), '/') === $data['secret']) {
121
                return $this->bypassResponse($data['secret']);
122
            }
123
124
            if ($this->hasValidBypassCookie($request, $data)) {
125
                return $handler->handle($request);
126
            }
127
128
            if (isset($data['template'])) {
129
                return $this->templateResponse($data['template'], $data);
130
            }
131
132
            $message = $data['message'] ?? 'The server is temporarily busy, try again later';
133
134
            $httpException = new HttpException(
135
                $request,
136
                $message,
137
                $data['status'] ?? 503
138
            );
139
140
            $httpException->setTitle('Service Unavailable')
141
                          ->setDescription($message);
142
143
            // TODO Add headers
144
            throw $httpException;
145
        }
146
147
        return $handler->handle($request);
148
    }
149
150
    protected function bypassResponse(string $secret): ResponseInterface
151
    {
152
        $routeHelper = $this->getRouteHelper();
153
        $bypassRouteName = $this->config->get('maintenance.bypass_route', '');
154
        $url = $routeHelper->generateUrl($bypassRouteName);
155
156
        // Cookie
157
        $manager = $this->getCookieManager();
158
        $cookie = $this->createBypassCookie($secret);
159
160
        $manager->add($cookie);
161
        $redirectResponse = new RedirectResponse($url);
162
163
        return $manager->send($redirectResponse);
164
    }
165
166
    /**
167
     * Return the template response
168
     * @param string $template
169
     * @param array<string, mixed> $data
170
     * @return ResponseInterface
171
     */
172
    protected function templateResponse(string $template, array $data): ResponseInterface
173
    {
174
        $response = new TemplateResponse(
175
            $this->getTemplate(),
176
            $template,
177
            $data,
178
            $data['status'] ?? 503
179
        );
180
181
        foreach ($this->getHeaders($data) as $name => $value) {
182
            $response = $response->withAddedHeader($name, $value);
183
        }
184
185
        return $response;
186
    }
187
188
    /**
189
     * Check whether it's request URL white list
190
     * @param ServerRequestInterface $request
191
     * @return bool
192
     */
193
    protected function isExceptUrl(ServerRequestInterface $request): bool
194
    {
195
        $path = $request->getUri()->getPath();
196
        foreach ($this->getExceptUrls() as $url) {
197
            if (Str::is($url, $path)) {
198
                return true;
199
            }
200
        }
201
202
        return false;
203
    }
204
205
    /**
206
     * Return the except URLs
207
     * @return array<string, string>
208
     */
209
    protected function getExceptUrls(): array
210
    {
211
        return $this->config->get('maintenance.url_whitelist', []);
212
    }
213
214
    /**
215
     * Whether has valid bypass cookie
216
     * @param ServerRequestInterface $request
217
     * @param array<string, mixed> $data
218
     * @return bool
219
     */
220
    protected function hasValidBypassCookie(ServerRequestInterface $request, array $data): bool
221
    {
222
        if (!isset($data['secret'])) {
223
            return false;
224
        }
225
        $secret = $data['secret'];
226
        $name = $this->getCookieName();
227
        $cookieValue = (new RequestData($request))->cookie($name);
228
        if ($cookieValue === null) {
229
            return false;
230
        }
231
232
        $payload = Json::decode(base64_decode($cookieValue), true);
233
234
        return is_array($payload) &&
235
                is_int($payload['expire'] ?? null) &&
236
                isset($payload['hash']) &&
237
                hash_equals(hash_hmac('sha256', (string) $payload['expire'], $secret), $payload['hash']) &&
238
                is_int($payload['expire']) >= time();
239
    }
240
241
    /**
242
     * Return the bypass cookie
243
     * @param string $secret
244
     * @return Cookie
245
     */
246
    protected function createBypassCookie(string $secret): Cookie
247
    {
248
        $name = $this->getCookieName();
249
        $expire = time() + $this->config->get('maintenance.cookie.lifetime', 43200);
250
        $data = [
251
            'expire' => $expire,
252
            'hash' => hash_hmac('sha256', (string) $expire, $secret)
253
        ];
254
        $value = base64_encode(Json::encode($data));
255
256
        $cookie = new Cookie($name, $value);
257
258
        $sessionCookieConfig = $this->config->get('session.cookie', []);
259
260
        $cookie->withExpires($expire)
261
               ->withPath($sessionCookieConfig['path'] ?? null)
262
               ->withDomain($sessionCookieConfig['domain'] ?? null)
263
               ->withHttpOnly(true);
264
265
266
        return $cookie;
267
    }
268
269
    /**
270
     * Return the headers to be added in the response
271
     * @param array<string, mixed> $data
272
     * @return array<string, mixed>
273
     */
274
    protected function getHeaders(array $data): array
275
    {
276
        $headers = isset($data['retry']) ? ['Retry-After' => $data['retry']] : [];
277
        if (isset($data['refresh'])) {
278
            $headers['Refresh'] = $data['refresh'];
279
        }
280
281
        return $headers;
282
    }
283
284
    /**
285
     * Return the template instance
286
     * @return Template
287
     */
288
    protected function getTemplate(): Template
289
    {
290
        return $this->app->get(Template::class);
291
    }
292
293
    /**
294
     * Return the route helper instance
295
     * @return RouteHelper
296
     */
297
    protected function getRouteHelper(): RouteHelper
298
    {
299
        return $this->app->get(RouteHelper::class);
300
    }
301
302
    /**
303
     * Return the Cookie Manager instance
304
     * @return CookieManager
305
     */
306
    protected function getCookieManager(): CookieManager
307
    {
308
        return $this->app->get(CookieManager::class);
309
    }
310
311
    /**
312
     * Return the cookie name
313
     * @return string
314
     */
315
    protected function getCookieName(): string
316
    {
317
        return $this->config->get('maintenance.cookie.name', 'platine_maintenance');
318
    }
319
}
320