Passed
Push — main ( c1749a...759bea )
by Dimitri
12:54
created

RestController::decodeToken()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 4
c 0
b 0
f 0
nc 2
nop 3
dl 0
loc 9
ccs 0
cts 3
cp 0
crap 6
rs 10
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11
12
namespace BlitzPHP\Controllers;
13
14
use BlitzPHP\Annotations\AnnotationReader;
15
use BlitzPHP\Annotations\Http\AjaxOnlyAnnotation;
16
use BlitzPHP\Annotations\Http\RequestMappingAnnotation;
17
use BlitzPHP\Container\Services;
18
use BlitzPHP\Contracts\Http\StatusCode;
19
use BlitzPHP\Exceptions\ValidationException;
20
use BlitzPHP\Formatter\Formatter;
21
use BlitzPHP\Traits\Http\ApiResponseTrait;
22
use BlitzPHP\Utilities\Jwt;
23
use Exception;
24
use mindplay\annotations\IAnnotation;
25
use Psr\Http\Message\ResponseInterface;
26
use stdClass;
27
use Throwable;
28
29
/**
30
 * Le contrôleur de base pour les API REST
31
 */
32
class RestController extends BaseController
33
{
34
    use ApiResponseTrait;
35
36
    /**
37
     * Configurations
38
     *
39
     * @var stdClass
40
     */
41
    protected $config;
42
43
    /**
44
     * Langue à utiliser
45
     *
46
     * @var string
47
     */
48
    private $locale;
49
50
    /**
51
     * Type mime associé à chaque format de sortie
52
     *
53
     * Répertoriez tous les formats pris en charge, le première sera le format par défaut.
54
     */
55
    protected $mimes = [
56
        'json' => 'application/json',
57
        'csv'  => 'application/csv',
58
        // 'html'       => 'text/html',
59
        'jsonp'      => 'application/javascript',
60
        'php'        => 'text/plain',
61
        'serialized' => 'application/vnd.php.serialized',
62
        'xml'        => 'application/xml',
63
64
        'array' => 'php/array',
65
    ];
66
67
    /**
68
     * @var array|object Payload provenant du token jwt
69
     */
70
    protected $payload;
71
72
    public function __construct()
73
    {
74
        $this->config = (object) config('rest');
0 ignored issues
show
Documentation Bug introduced by
It seems like (object)config('rest') of type BlitzPHP\Config\Config is incompatible with the declared type stdClass of property $config.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
75
76
        $locale       = $this->config->language ?? null;
0 ignored issues
show
Bug introduced by
The property language does not seem to exist on BlitzPHP\Config\Config.
Loading history...
77
        $this->locale = ! empty($locale) ? $locale : $this->request->getLocale();
78
    }
79
80
    public function _remap(string $method, array $params = [])
81
    {
82
        $class = static::class;
83
84
        // Bien sûr qu'il existe, mais peuvent-ils en faire quelque chose ?
85
        if (! method_exists($class, $method)) {
86
            return $this->respondNotImplemented($this->_translate('notImplemented', [$class, $method]));
87
        }
88
89
        // Appel de la méthode du contrôleur et passage des arguments
90
        try {
91
            $instance = Services::container()->get($class);
92
            $instance->initialize($this->request, $this->response, $this->logger);
93
94
            $instance = $this->_execAnnotations($instance, AnnotationReader::fromClass($instance));
95
            $instance = $this->_execAnnotations($instance, AnnotationReader::fromMethod($instance, $method));
96
97
            $checkProcess = $this->checkProcess();
98
            if ($checkProcess instanceof ResponseInterface) {
0 ignored issues
show
introduced by
$checkProcess is always a sub-type of Psr\Http\Message\ResponseInterface.
Loading history...
99
                return $checkProcess;
100
            }
101
102
            $instance->payload = $this->payload;
103
104
            $response = Services::container()->call([$instance, $method], (array) $params);
105
106
            if ($response instanceof ResponseInterface) {
107
                return $response;
108
            }
109
110
            return $this->respondOk($response);
111
        } catch (Throwable $ex) {
112
            return $this->manageException($ex);
113
        }
114
    }
115
116
    /**
117
     * Gestionnaire des exceptions
118
     *
119
     * Ceci permet aux classes filles de specifier comment elles doivent gerer les exceptions lors de la methode remap
120
     *
121
     * @return \Psr\Http\Message\ResponseInterface
122
     */
123
    protected function manageException(Throwable $ex)
124
    {
125
        if ($ex instanceof ValidationException) {
126
            $message = 'Validation failed';
127
            $errors  = $ex->getErrors()->all();
128
129
            return $this->respondBadRequest($message, $ex->getCode(), $errors);
130
        }
131
132
        if (! on_dev()) {
133
            $url = explode('?', $this->request->getRequestTarget())[0];
134
135
            return $this->respondBadRequest($this->_translate('badUsed', [$url]));
136
        }
137
138
        return $this->respondInternalError('Internal Server Error', [
139
            'type'    => get_class($ex),
140
            'message' => $ex->getMessage(),
141
            'code'    => $ex->getCode(),
142
            'file'    => $ex->getFile(),
143
            'line'    => $ex->getLine(),
144
        ]);
145
    }
146
147
    /**
148
     * Fournit une méthode simple et unique pour renvoyer une réponse d'API, formatée
149
     * pour correspondre au format demandé, avec le type de contenu et le code d'état appropriés.
150
     *
151
     * @param mixed    $data   Les donnees a renvoyer
152
     * @param int|null $status Le statut de la reponse
153
     */
154
    final protected function respond($data, ?int $status = StatusCode::OK)
155
    {
156
        // Si les données sont NULL et qu'aucun code d'état HTTP n'est fourni, affichage, erreur et sortie
157
        if ($data === null && $status === null) {
158
            $status = StatusCode::NOT_FOUND;
159
        }
160
161
        $this->response = $this->response->withStatus($status)->withCharset(strtolower(config('app.charset') ?? 'utf-8'));
0 ignored issues
show
Bug introduced by
It seems like config('app.charset') ?? 'utf-8' can also be of type BlitzPHP\Config\Config; however, parameter $string of strtolower() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

161
        $this->response = $this->response->withStatus($status)->withCharset(strtolower(/** @scrutinizer ignore-type */ config('app.charset') ?? 'utf-8'));
Loading history...
162
163
        $this->_parseResponse($data);
164
165
        return $this->response;
166
    }
167
168
    /**
169
     * Utilisé pour les échecs génériques pour lesquels aucune méthode personnalisée n'existe.
170
     *
171
     * @param string          $message Le message décrivant l'erreur
172
     * @param int|string|null $code    Code d'erreur personnalisé, spécifique à l'API
173
     * @param array           $errors  La liste des erreurs rencontrées
174
     *
175
     * @return \Psr\Http\Message\ResponseInterface
176
     */
177
    final protected function respondFail(?string $message = "Une erreur s'est produite", ?int $status = StatusCode::INTERNAL_ERROR, null|int|string $code = null, array $errors = [])
178
    {
179
        $message = $message ?: "Une erreur s'est produite";
180
        $code    = ! empty($code) ? $code : $status;
181
182
        $response = [
183
            $this->config->field['message'] ?? 'message' => $message,
184
        ];
185
        if (! empty($this->config->field['status'])) {
186
            $response[$this->config->field['status']] = false;
187
        }
188
        if (! empty($this->config->field['code'])) {
189
            $response[$this->config->field['code']] = $code;
190
        }
191
        if (! empty($errors)) {
192
            $response[$this->config->field['errors'] ?? 'errors'] = $errors;
193
        }
194
195
        if ($this->config->strict !== true) {
196
            $status = StatusCode::OK;
197
        }
198
199
        return $this->respond($response, $status);
200
    }
201
202
    /**
203
     * Utilisé pour les succès génériques pour lesquels aucune méthode personnalisée n'existe.
204
     *
205
     * @param mixed|null $result Les données renvoyées par l'API
206
     *
207
     * @return \Psr\Http\Message\ResponseInterface
208
     */
209
    final protected function respondSuccess(?string $message = 'Resultat', $result = null, ?int $status = StatusCode::OK)
210
    {
211
        $message = $message ?: 'Resultat';
212
        $status  = ! empty($status) ? $status : StatusCode::OK;
213
214
        $response = [
215
            $this->config->field['message'] ?? 'message' => $message,
216
        ];
217
        if (! empty($this->config->field['status'])) {
218
            $response[$this->config->field['status']] = true;
219
        }
220
        if (is_array($result)) {
221
            $result = array_map(fn ($element) => $this->formatEntity($element), $result);
222
        }
223
224
        $response[$this->config->field['result'] ?? 'result'] = $this->formatEntity($result);
225
226
        return $this->respond($response, $status);
227
    }
228
229
    /**
230
     * Formatte les données à renvoyer lorsqu'il s'agit des objets de la classe Entity
231
     *
232
     * @param mixed $element
233
     *
234
     * @return mixed
235
     */
236
    protected function formatEntity($element)
237
    {
238
        /*
239
        if ($element instanceof Entity) {
240
            if (method_exists($element, 'format')) {
241
                return Services::injector()->call([$element, 'format']);
242
            }
243
244
            return call_user_func([$element, 'toArray']);
245
        }
246
        */
247
        return $element;
248
    }
249
250
    /**
251
     * Genere un token d'authentification
252
     */
253
    protected function generateToken(array $data = [], array $config = []): string
254
    {
255
        $config = array_merge(['base_url' => base_url()], $this->config->jwt ?? [], $config);
256
257
        return Jwt::encode($data, $config);
258
    }
259
260
    /**
261
     * Decode un token d'autorisation
262
     *
263
     * @return mixed
264
     */
265
    protected function decodeToken(string $token, string $authType = 'bearer', array $config = [])
266
    {
267
        $config = array_merge(['base_url' => base_url()], $this->config->jwt ?? [], $config);
268
269
        if ('bearer' === $authType) {
270
            return JWT::decode($token, $config);
271
        }
272
273
        return null;
274
    }
275
276
    /**
277
     * Recupere le token d'acces a partier des headers
278
     */
279
    protected function getBearerToken(): ?string
280
    {
281
        return Jwt::getToken();
282
    }
283
284
    /**
285
     * Recupere le header "Authorization"
286
     */
287
    protected function getAuthorizationHeader(): ?string
288
    {
289
        return Jwt::getAuthorization();
290
    }
291
292
    /**
293
     * Une méthode pratique pour traduire une chaîne ou un tableau d'entrées et
294
     * formater le résultat avec le MessageFormatter de l'extension intl.
295
     */
296
    protected function lang(string $line, ?array $args = null): string
297
    {
298
        return lang($line, $args, $this->locale);
0 ignored issues
show
Bug introduced by
It seems like $args can also be of type null; however, parameter $args of lang() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

298
        return lang($line, /** @scrutinizer ignore-type */ $args, $this->locale);
Loading history...
299
    }
300
301
    /**
302
     * @internal Ne pas censé être utilisé par le developpeur
303
     */
304
    protected function _translate(string $line, ?array $args = null): string
305
    {
306
        return $this->lang('Rest.' . $line, $args);
307
    }
308
309
    /**
310
     * Specifie que seules les requetes ajax sont acceptees
311
     */
312
    final protected function ajaxOnly(): self
313
    {
314
        $this->config->ajax_only = true;
315
316
        return $this;
317
    }
318
319
    /**
320
     * Definit les methodes authorisees par le web service
321
     */
322
    final protected function allowedMethods(string ...$methods): self
323
    {
324
        if (! empty($methods)) {
325
            $this->config->allowed_methods = array_map(static fn ($str) => strtoupper($str), $methods);
326
        }
327
328
        return $this;
329
    }
330
331
    /**
332
     * Definit le format de donnees a renvoyer au client
333
     */
334
    final protected function returnFormat(string $format): self
335
    {
336
        $this->config->format = $format;
337
338
        return $this;
339
    }
340
341
    /**
342
     * N'autorise que les acces pas https
343
     */
344
    final protected function requireHttps(): self
345
    {
346
        $this->config->force_https = true;
347
348
        return $this;
349
    }
350
351
    /**
352
     * auth
353
     *
354
     * @param false|string $type
355
     */
356
    final protected function auth($type): self
357
    {
358
        $this->config->auth = $type;
359
360
        return $this;
361
    }
362
363
    /**
364
     * Definit la liste des adresses IP a bannir
365
     * Si le premier argument vaut "false", la suite ne sert plus a rien
366
     */
367
    final protected function ipBlacklist(...$params): self
368
    {
369
        $params = func_get_args();
370
        $enable = array_shift($params);
371
372
        if (false === $enable) {
373
            $params = [];
374
        } else {
375
            array_unshift($params, $enable);
376
            $params = array_merge($this->config->ip_blacklist ?? [], $params);
377
        }
378
379
        $this->config->ip_blacklist = $params;
380
381
        return $this;
382
    }
383
384
    /**
385
     * Definit la liste des adresses IP qui sont autorisees a acceder a la ressources
386
     * Si le premier argument vaut "false", la suite ne sert plus a rien
387
     */
388
    final protected function ipWhitelist(...$params): self
389
    {
390
        $params = func_get_args();
391
        $enable = array_shift($params);
392
393
        if (false === $enable) {
394
            $params = [];
395
        } else {
396
            array_unshift($params, $enable);
397
            $params = array_merge($this->config->ip_whitelist ?? [], $params);
398
        }
399
400
        $this->config->ip_whitelist = $params;
401
402
        return $this;
403
    }
404
405
    /**
406
     * Formatte les donnees a envoyer au bon format
407
     *
408
     * @param mixed $data Les donnees a envoyer
409
     */
410
    private function _parseResponse($data)
411
    {
412
        $format = strtolower($this->config->format);
413
        $mime   = null;
414
415
        if (array_key_exists($format, $this->mimes)) {
416
            $mime = $this->mimes[$format];
417
        } elseif (in_array($format, $this->mimes, true)) {
418
            $mime = $format;
419
        }
420
421
        // Si la méthode de format existe, appelle et renvoie la sortie dans ce format
422
        if (! empty($mime)) {
423
            $output = Formatter::type($mime)->format($data);
424
425
            // Définit l'en-tête du format
426
            // Ensuite, vérifiez si le client a demandé un rappel, et si la sortie contient ce rappel :
427
            $callback = $this->request->getQuery('callback');
428
            if (! empty($callback) && $mime === $this->mimes['json'] && preg_match('/^' . $callback . '/', $output)) {
0 ignored issues
show
Bug introduced by
Are you sure $callback of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

428
            if (! empty($callback) && $mime === $this->mimes['json'] && preg_match('/^' . /** @scrutinizer ignore-type */ $callback . '/', $output)) {
Loading history...
429
                $this->response = $this->response->withType($this->mimes['jsonp']);
430
            } else {
431
                $this->response = $this->response->withType($mime === $this->mimes['array'] ? $this->mimes['json'] : $mime);
432
            }
433
434
            // Un tableau doit être analysé comme une chaîne, afin de ne pas provoquer d'erreur de tableau en chaîne
435
            // Json est la forme la plus appropriée pour un tel type de données
436
            if ($mime === $this->mimes['array']) {
437
                $output = Formatter::type($this->mimes['json'])->format($output);
438
            }
439
        } else {
440
            // S'il s'agit d'un tableau ou d'un objet, analysez-le comme un json, de manière à être une 'chaîne'
441
            if (is_array($data) || is_object($data)) {
442
                $data = Formatter::type($this->mimes['json'])->format($data);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type object; however, parameter $data of BlitzPHP\Formatter\FormatterInterface::format() does only seem to accept array|string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

442
                $data = Formatter::type($this->mimes['json'])->format(/** @scrutinizer ignore-type */ $data);
Loading history...
443
            }
444
            // Le format n'est pas pris en charge, sortez les données brutes sous forme de chaîne
445
            $output = $data;
446
        }
447
448
        $this->response = $this->response->withStringBody($output);
449
    }
450
451
    /**
452
     * Execute les annotations definies dans le contrôleur
453
     *
454
     * @param IAnnotation[] $annotations Liste des annotations d'un contrôleur/méthode
455
     */
456
    protected function _execAnnotations(self $instance, array $annotations): self
457
    {
458
        foreach ($annotations as $annotation) {
459
            switch (get_type_name($annotation)) {
460
                case RequestMappingAnnotation::class:
461
                    $this->allowedMethods(...(array) $annotation->method);
0 ignored issues
show
Bug introduced by
Accessing method on the interface mindplay\annotations\IAnnotation suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
462
                    break;
463
464
                case AjaxOnlyAnnotation::class:
465
                    $this->ajaxOnly();
466
                    break;
467
468
                default:
469
                    break;
470
            }
471
        }
472
473
        return $instance;
474
    }
475
476
    /**
477
     * Verifie si les informations fournis par le client du ws sont conforme aux attentes du developpeur
478
     *
479
     * @throws Exception
480
     */
481
    private function checkProcess(): bool|ResponseInterface
482
    {
483
        // Verifie si la requete est en ajax
484
        if (! $this->request->is('ajax') && $this->config->ajax_only) {
485
            return $this->respondNotAcceptable($this->_translate('ajaxOnly'));
486
        }
487
488
        // Verifie si la requete est en https
489
        if (! $this->request->is('https') && $this->config->force_https) {
490
            return $this->respondForbidden($this->_translate('unsupported'));
491
        }
492
493
        // Verifie si la methode utilisee pour la requete est autorisee
494
        if (! in_array(strtoupper($this->request->getMethod()), $this->config->allowed_methods, true)) {
495
            return $this->respondNotAcceptable($this->_translate('unknownMethod'));
496
        }
497
498
        // Verifie que l'ip qui emet la requete n'est pas dans la blacklist
499
        if (! empty($this->config->ip_blacklis)) {
500
            $this->config->ip_blacklist = implode(',', $this->config->ip_blacklist);
501
502
            // Correspond à une adresse IP dans une liste noire, par ex. 127.0.0.0, 0.0.0.0
503
            $pattern = sprintf('/(?:,\s*|^)\Q%s\E(?=,\s*|$)/m', $this->request->clientIp());
504
505
            // Renvoie 1, 0 ou FALSE (en cas d'erreur uniquement). Donc convertir implicitement 1 en TRUE
506
            if (preg_match($pattern, $this->config->ip_blacklist)) {
507
                return $this->respondUnauthorized($this->_translate('ipDenied'));
508
            }
509
        }
510
511
        // Verifie que l'ip qui emet la requete est dans la whitelist
512
        if (! empty($this->config->ip_whitelist)) {
513
            $whitelist = $this->config->ip_whitelist;
514
            array_push($whitelist, '127.0.0.1', '0.0.0.0');
515
516
            // coupez les espaces de début et de fin des ip
517
            $whitelist = array_map('trim', $whitelist);
518
519
            if (! in_array($this->request->clientIp(), $whitelist, true)) {
520
                return $this->respondUnauthorized($this->_translate('ipUnauthorized'));
521
            }
522
        }
523
524
        // Verifie l'authentification du client
525
        if (false !== $this->config->auth && ! $this->request->is('options')) {
526
            if ('bearer' === strtolower($this->config->auth)) {
527
                $token = $this->getBearerToken();
528
                if (empty($token)) {
529
                    return $this->respondInvalidToken($this->_translate('tokenNotFound'));
530
                }
531
532
                $payload = $this->decodeToken($token, 'bearer');
533
                if ($payload instanceof Throwable) {
534
                    return $this->respondInvalidToken($payload->getMessage());
535
                }
536
                $this->payload = $payload;
537
            }
538
        }
539
540
        return true;
541
    }
542
}
543