Passed
Push — main ( 1fe2cd...c1deb1 )
by Dimitri
04:10
created

AutoRouter::getPos()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 6
ccs 0
cts 0
cp 0
crap 2
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\Router;
13
14
use BlitzPHP\Contracts\Router\AutoRouterInterface;
15
use BlitzPHP\Exceptions\MethodNotFoundException;
16
use BlitzPHP\Exceptions\PageNotFoundException;
17
use BlitzPHP\Utilities\Helpers;
18
use BlitzPHP\Utilities\String\Text;
19
use ReflectionClass;
20
use ReflectionException;
21
22
/**
23
 * Routeur sécurisé pour le routage automatique
24
 *
25
 * @credit <a href="http://www.codeigniter.com">CodeIgniter 4.4 - CodeIgniter\Router\AutoRouterImproved</a>
26
 */
27
final class AutoRouter implements AutoRouterInterface
28
{
29
    /**
30
     * Sous-répertoire contenant la classe contrôleur demandée.
31
     * Principalement utilisé par 'autoRoute'.
32
     */
33
    private ?string $directory = null;
34
35
    /**
36
     * Le nom de la classe du contrôleur.
37
     */
38
    private string $controller;
39
40
    /**
41
     * Nom de la méthode à utiliser.
42
     */
43
    private string $method;
44
45
    /**
46
     * Tableau de paramètres de la méthode du contrôleur.
47
     *
48
     * @var string[]
49
     */
50
    private array $params = [];
51
52
    /**
53
     *  Indique si on doit traduire les tirets de l'URL pour les controleurs/methodes en CamelCase.
54
     *  E.g., blog-controller -> BlogController
55
     */
56
    private bool $translateUriToCamelCase;
57
58
    /**
59
     * Namespace des controleurs
60
     */
61
    private string $namespace;
62
63
    /**
64
     * Segments de l'URI
65
     *
66
     * @var string[]
67
     */
68
    private array $segments = [];
69
70
    /**
71
     * Position du contrôleur dans les segments URI.
72
     * Null pour le contrôleur par défaut.
73
     */
74
    private ?int $controllerPos = null;
75
76
    /**
77
     * Position de la méthode dans les segments URI.
78
     * Null pour la méthode par défaut.
79
     */
80
    private ?int $methodPos = null;
81
82
    /**
83
     * Position du premier Paramètre dans les segments URI.
84
     * Null pour les paramètres non definis.
85
     */
86
    private ?int $paramPos = null;
87
88
    /**
89
     * Carte des segments URI et des namespace.
90
     *
91
     * La clé est le premier segment URI. La valeur est le namespace du contrôleur.
92
     * Ex.,
93
     *   [
94
     *       'blog' => 'Acme\Blog\Controllers',
95
     *   ]
96
     *
97
     * @var array [ uri_segment => namespace ]
98
     */
99
    private array $moduleRoutes;
100
101
    /**
102
     * URI courant
103
     */
104
    private ?string $uri = null;
105
106
    /**
107
     * Constructeur
108
     *
109
     * @param class-string[] $protectedControllers Liste des contrôleurs enregistrés pour le verbe CLI qui ne doivent pas être accessibles sur le Web.
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string[] at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string[].
Loading history...
110
     * @param string         $namespace            Espace de noms par défaut pour les contrôleurs.
111
     * @param string         $defaultController    Nom du controleur par defaut.
112
     * @param string         $defaultMethod        Nom de la methode par defaut.
113
     * @param bool           $translateURIDashes   Indique si les tirets dans les URI doivent être convertis en traits de soulignement lors de la détermination des noms de méthode.
114
     */
115
    public function __construct(
116
        private array $protectedControllers,
117
        string $namespace,
118
        private string $defaultController,
119
        private string $defaultMethod,
120
        private bool $translateURIDashes
121
    ) {
122 2
        $this->namespace = rtrim($namespace, '\\');
123
124 2
        $routingConfig                 = (object) config('routing');
125 2
        $this->moduleRoutes            = $routingConfig->module_routes;
0 ignored issues
show
Bug introduced by
The property module_routes does not seem to exist on BlitzPHP\Config\Config.
Loading history...
126 2
        $this->translateUriToCamelCase = $routingConfig->translate_uri_to_camel_case;
0 ignored issues
show
Bug introduced by
The property translate_uri_to_camel_case does not seem to exist on BlitzPHP\Config\Config.
Loading history...
127
128
        // Definir les valeurs par defaut
129 2
        $this->controller = $this->defaultController;
130
    }
131
132
    private function createSegments(string $uri): array
133
    {
134 2
        $segments = explode('/', $uri);
135 2
        $segments = array_filter($segments, static fn ($segment) => $segment !== '');
136
137
        // réindexer numériquement le tableau, en supprimant les lacunes
138 2
        return array_values($segments);
139
    }
140
141
    /**
142
     * Recherchez le premier contrôleur correspondant au segment URI.
143
     *
144
     * S'il y a un contrôleur correspondant au premier segment, la recherche s'arrête là.
145
     * Les segments restants sont des paramètres du contrôleur.
146
     *
147
     * @return bool true si une classe de contrôleur est trouvée.
148
     */
149
    private function searchFirstController(): bool
150
    {
151 2
        $segments = $this->segments;
152
153 2
        $controller = '\\' . $this->namespace;
154
155 2
        $controllerPos = -1;
156
157
        while ($segments !== []) {
158 2
            $segment = array_shift($segments);
159 2
            $controllerPos++;
160
161 2
            $class = $this->translateURIDashes(ucfirst($segment));
162
163
            // dès que nous rencontrons un segment qui n'est pas compatible PSR-4, arrêter la recherche
164
            if (! $this->isValidSegment($class)) {
165
                return false;
166
            }
167
168 2
            $controller = $this->makeController($controller . '\\' . $class);
169
170
            if (class_exists($controller)) {
171 2
                $this->controller    = $controller;
172 2
                $this->controllerPos = $controllerPos;
173
174 2
                $this->checkUriForController($controller);
175
176
                // Le premier élément peut être un nom de méthode.
177 2
                $this->params = $segments;
178
                if ($segments !== []) {
179
                    $this->paramPos = $this->controllerPos + 1;
180
                }
181
182 2
                return true;
183
            }
184
        }
185
186
        return false;
187
    }
188
189
    /**
190
     * Recherchez le dernier contrôleur par défaut correspondant aux segments URI.
191
     *
192
     * @return bool true si une classe de contrôleur est trouvée.
193
     */
194
    private function searchLastDefaultController(): bool
195
    {
196
        $segments = $this->segments;
197
198
        $segmentCount = count($this->segments);
199
        $paramPos     = null;
200
        $params       = [];
201
202
        while ($segments !== []) {
203
            if ($segmentCount > count($segments)) {
204
                $paramPos = count($segments);
205
            }
206
207
            $namespaces = array_map(
208
                fn ($segment) => $this->translateURIDashes(ucfirst($segment)),
209
                $segments
210
            );
211
212
            $controller = '\\' . $this->namespace
213
                . '\\' . implode('\\', $namespaces)
214
                . '\\' . $this->defaultController;
215
216
            if (class_exists($controller)) {
217
                $this->controller = $controller;
218
                $this->params     = $params;
219
220
                if ($params !== []) {
221
                    $this->paramPos = $paramPos;
222
                }
223
224
                return true;
225
            }
226
227
            // ajoutons le dernier élément dans $segments au début de $params.
228
            array_unshift($params, array_pop($segments));
229
        }
230
231
        // Vérifiez le contrôleur par défaut dans le répertoire des contrôleurs.
232
        $controller = '\\' . $this->namespace
233
            . '\\' . $this->defaultController;
234
235
        if (class_exists($controller)) {
236
            $this->controller = $controller;
237
            $this->params     = $params;
238
239
            if ($params !== []) {
240
                $this->paramPos = 0;
241
            }
242
243
            return true;
244
        }
245
246
        return false;
247
    }
248
249
    /**
250
     * Recherche contrôleur, méthode et params dans l'URI.
251
     *
252
     * @return array [directory_name, controller_name, controller_method, params]
253
     */
254
    public function getRoute(string $uri, string $httpVerb): array
255
    {
256 2
        $this->uri = $uri;
257 2
        $httpVerb  = strtolower($httpVerb);
258
259
        // Reinitialise les parametres de la methode du controleur.
260 2
        $this->params = [];
261
262 2
        $defaultMethod = $httpVerb . ucfirst($this->defaultMethod);
263 2
        $this->method  = $defaultMethod;
264
265 2
        $this->segments = $this->createSegments($uri);
266
267
        // Verifier les routes de modules
268
        if ($this->segments !== [] && array_key_exists($this->segments[0], $this->moduleRoutes)) {
269 2
            $uriSegment      = array_shift($this->segments);
270
            $this->namespace = rtrim($this->moduleRoutes[$uriSegment], '\\');
271
        }
272
273
        if ($this->searchFirstController()) {
274
            // Le contrôleur a ete trouvé.
275 2
            $baseControllerName = Helpers::classBasename($this->controller);
276
277
            // Empêcher l'accès au chemin de contrôleur par défaut
278
            if (strtolower($baseControllerName) === strtolower($this->defaultController)) {
279
                throw new PageNotFoundException(
280
                    'Impossible d\'accéder au contrôleur par défaut "' . $this->controller . '" avec le nom du contrôleur comme chemin de l\'URI.'
281 2
                );
282
            }
283
        } elseif ($this->searchLastDefaultController()) {
284
            // Le controleur par defaut a ete trouve.
285
            $baseControllerName = Helpers::classBasename($this->controller);
286
        } else {
287
            // Aucun controleur trouvé
288
            throw new PageNotFoundException('Aucun contrôleur trouvé pour: ' . $uri);
289
        }
290
291
        // Le premier élément peut être un nom de méthode.
292
        /** @var string[] $params */
293 2
        $params = $this->params;
294
295 2
        $methodParam = array_shift($params);
296
297 2
        $method = '';
298
        if ($methodParam !== null) {
299 2
            $method = $httpVerb . ucfirst($this->translateURIDashes($methodParam));
300
301
            $this->checkUriForMethod($method);
302
        }
303
304
        if ($methodParam !== null && method_exists($this->controller, $method)) {
305
            // Methode trouvee.
306
            $this->method = $method;
307
            $this->params = $params;
308
309
            // Mise a jour des positions.
310
            $this->methodPos = $this->paramPos;
311
            if ($params === []) {
312
                $this->paramPos = null;
313
            }
314
            if ($this->paramPos !== null) {
315
                $this->paramPos++;
316
            }
317
318
            // Empêcher l'accès à la méthode du contrôleur par défaut
319
            if (strtolower($baseControllerName) === strtolower($this->defaultController)) {
320
                throw new PageNotFoundException(
321
                    'Impossible d\'accéder au contrôleur par défaut "' . $this->controller . '::' . $this->method . '"'
322
                );
323
            }
324
325
            // Empêcher l'accès au chemin de méthode par défaut
326
            if (strtolower($this->method) === strtolower($defaultMethod)) {
327
                throw new PageNotFoundException(
328
                    'Impossible d\'accéder à la méthode par défaut "' . $this->method . '" avec le nom de méthode comme chemin d\'URI.'
329
                );
330
            }
331
        } elseif (method_exists($this->controller, $defaultMethod)) {
332
            // La methode par defaut a ete trouvée
333 2
            $this->method = $defaultMethod;
334
        } else {
335
            // Aucune methode trouvee
336
            throw PageNotFoundException::controllerNotFound($this->controller, $method);
337
        }
338
339
        // Vérifiez le contrôleur n'est pas défini dans les routes.
340 2
        $this->protectDefinedRoutes();
341
342
        // Assurez-vous que le contrôleur n'a pas la méthode _remap().
343 2
        $this->checkRemap();
344
345
        // Assurez-vous que les segments URI pour le contrôleur et la méthode
346
        // ne contiennent pas de soulignement lorsque $translateURIDashes est true.
347 2
        $this->checkUnderscore($uri);
348
349
        // Verifier le nombre de parametres
350
        try {
351 2
            $this->checkParameters($uri);
352
        } catch (MethodNotFoundException $e) {
353
            throw PageNotFoundException::controllerNotFound($this->controller, $this->method);
354
        }
355
356 2
        $this->setDirectory();
357
358 2
        return [$this->directory, $this->controllerName(), $this->methodName(), $this->params];
359
    }
360
361
    /**
362
     * @internal Juste pour les tests.
363
     *
364
     * @return array<string, int|null>
365
     */
366
    public function getPos(): array
367
    {
368
        return [
369
            'controller' => $this->controllerPos,
370
            'method'     => $this->methodPos,
371
            'params'     => $this->paramPos,
372
        ];
373
    }
374
375
    private function checkParameters(string $uri): void
376
    {
377
        try {
378 2
            $refClass = new ReflectionClass($this->controller);
379
        } catch (ReflectionException $e) {
380
            throw PageNotFoundException::controllerNotFound($this->controller, $this->method);
381
        }
382
383
        try {
384 2
            $refMethod = $refClass->getMethod($this->method);
385 2
            $refParams = $refMethod->getParameters();
386
        } catch (ReflectionException $e) {
387
            throw new MethodNotFoundException();
388
        }
389
390
        if (! $refMethod->isPublic()) {
391 2
            throw new MethodNotFoundException();
392
        }
393
394
        if (count($refParams) < count($this->params)) {
395
            throw new PageNotFoundException(
396
                'Le nombre de param dans l\'URI est supérieur aux paramètres de la méthode du contrôleur.'
397
                . ' Handler:' . $this->controller . '::' . $this->method
398
                . ', URI:' . $uri
399 2
            );
400
        }
401
    }
402
403
    private function checkRemap(): void
404
    {
405
        try {
406 2
            $refClass = new ReflectionClass($this->controller);
407 2
            $refClass->getMethod('_remap');
408
409
            throw new PageNotFoundException(
410
                'AutoRouterImproved ne prend pas en charge la methode `_remap()`.'
411
                . ' Contrôleur:' . $this->controller
412
            );
413
        } catch (ReflectionException $e) {
414
            // Ne rien faire
415
        }
416
    }
417
418
    private function checkUnderscore(string $uri): void
419
    {
420
        if ($this->translateURIDashes === false) {
421
            return;
422
        }
423
424 2
        $paramPos = $this->paramPos ?? count($this->segments);
425
426 2
        for ($i = 0; $i < $paramPos; $i++) {
427
            if (str_contains($this->segments[$i], '_')) {
428
                throw new PageNotFoundException(
429
                    'AutoRouterImproved interdit l\'accès à l\'URI'
430
                    . ' contenant les undescore ("' . $this->segments[$i] . '")'
431
                    . ' quand $translate_uri_dashes est activé.'
432
                    . ' Veuillez utiliser les tiret.'
433
                    . ' Handler:' . $this->controller . '::' . $this->method
434
                    . ', URI:' . $uri
435 2
                );
436
            }
437
        }
438
    }
439
440
    /**
441
     * Vérifier l'URI du contrôleur pour $translateUriToCamelCase
442
     *
443
     * @param string $classname Nom de classe du contrôleur généré à partir de l'URI.
444
     *                          La casse peut être un peu incorrecte.
445
     */
446
    private function checkUriForController(string $classname): void
447
    {
448
        if ($this->translateUriToCamelCase === false) {
449 2
            return;
450
        }
451
452
        if (! in_array(ltrim($classname, '\\'), get_declared_classes(), true)) {
453
            throw new PageNotFoundException(
454
                '"' . $classname . '" n\'a pas été trouvé.'
455
            );
456
        }
457
    }
458
459
    /**
460
     * Vérifier l'URI pour la méthode $translateUriToCamelCase
461
     *
462
     * @param string $method Nom de la méthode du contrôleur généré à partir de l'URI.
463
     *                       La casse peut être un peu incorrecte.
464
     */
465
    private function checkUriForMethod(string $method): void
466
    {
467
        if ($this->translateUriToCamelCase === false) {
468
            return;
469
        }
470
471
        if (! in_array($method, get_class_methods($this->controller), true)) {
472
            throw new PageNotFoundException(
473
                '"' . $this->controller . '::' . $method . '()" n\'a pas été trouvé.'
474
            );
475
        }
476
    }
477
478
    /**
479
     * Renvoie true si la chaîne $segment fournie représente un segment d'espace de noms/répertoire valide conforme à PSR-4
480
     *
481
     * La regex vient de https://www.php.net/manual/en/language.variables.basics.php
482
     */
483
    private function isValidSegment(string $segment): bool
484
    {
485 2
        return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment);
486
    }
487
488
    private function translateURIDashes(string $segment): string
489
    {
490
        if ($this->translateUriToCamelCase) {
491
            if (strtolower($segment) !== $segment) {
492
                throw new PageNotFoundException(
493
                    'AutoRouter interdit l\'accès à l\'URI'
494
                    . ' contenant des lettres majuscules ("' . $segment . '")'
495
                    . ' lorsque $translateUriToCamelCase est activé.'
496
                    . ' Veuillez utiliser le tiret.'
497
                    . ' URI:' . $this->uri
498
                );
499
            }
500
501
            if (str_contains($segment, '--')) {
502
                throw new PageNotFoundException(
503
                    'AutoRouter interdit l\'accès à l\'URI'
504
                    . ' contenant un double tiret ("' . $segment . '")'
505
                    . ' lorsque $translateUriToCamelCase est activé.'
506
                    . ' Veuillez utiliser le tiret simple.'
507
                    . ' URI:' . $this->uri
508
                );
509
            }
510
511
            return str_replace(
512
                ' ',
513
                '',
514
                ucwords(
515
                    preg_replace('/[\-]+/', ' ', $segment)
516
                )
517
            );
518
        }
519
520 2
        $segment = ucfirst($segment);
521
522
        if ($this->translateURIDashes) {
523 2
            return str_replace('-', '_', $segment);
524
        }
525
526
        return $segment;
527
    }
528
529
    /**
530
     * Obtenez le chemin du dossier du contrôleur et définissez-le sur la propriété.
531
     */
532
    private function setDirectory(): void
533
    {
534 2
        $segments = explode('\\', trim($this->controller, '\\'));
535
536
        // Supprimer le court nom de classe.
537 2
        array_pop($segments);
538
539 2
        $namespaces = implode('\\', $segments);
540
541
        $dir = str_replace(
542
            '\\',
543
            '/',
544
            ltrim(substr($namespaces, strlen($this->namespace)), '\\')
545 2
        );
546
547
        if ($dir !== '') {
548 2
            $this->directory = $dir . '/';
549
        }
550
    }
551
552
    private function protectDefinedRoutes(): void
553
    {
554 2
        $controller = strtolower($this->controller);
555
556
        foreach ($this->protectedControllers as $controllerInRoutes) {
557 2
            $routeLowerCase = strtolower($controllerInRoutes);
558
559
            if ($routeLowerCase === $controller) {
560
                throw new PageNotFoundException(
561
                    'Impossible d\'accéder à un contrôleur définie dans les routes. Contrôleur : ' . $controllerInRoutes
562
                );
563
            }
564
        }
565
    }
566
567
    /**
568
     * Renvoie le nom du sous-répertoire dans lequel se trouve le contrôleur.
569
     * Relatif à CONTROLLER_PATH
570
     *
571
     * @deprecated 1.0
572
     */
573
    public function directory(): string
574
    {
575
        return ! empty($this->directory) ? $this->directory : '';
576
    }
577
578
    /**
579
     * Renvoie le nom du contrôleur matché
580
     */
581
    private function controllerName(): string
582
    {
583
        return $this->translateURIDashes
584
            ? str_replace('-', '_', trim($this->controller, '/\\'))
585 2
            : Text::convertTo($this->controller, 'pascal');
586
    }
587
588
    /**
589
     * Retourne le nom de la méthode à exécuter
590
     */
591
    private function methodName(): string
592
    {
593
        return $this->translateURIDashes
594
            ? str_replace('-', '_', $this->method)
595 2
            : Text::convertTo($this->method, 'camel');
596
    }
597
598
    /**
599
     * Construit un nom de contrôleur valide
600
     */
601
    public function makeController(string $name): string
602
    {
603
        return preg_replace(
604
            ['#(\_)?Controller$#i', '#' . config('app.url_suffix') . '$#i'],
0 ignored issues
show
Bug introduced by
Are you sure config('app.url_suffix') of type BlitzPHP\Config\Config|null 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

604
            ['#(\_)?Controller$#i', '#' . /** @scrutinizer ignore-type */ config('app.url_suffix') . '$#i'],
Loading history...
605
            '',
606
            ucfirst($name)
607 2
        ) . 'Controller';
608
    }
609
}
610