• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 12673986434

08 Jan 2025 03:42PM UTC coverage: 84.455% (+0.001%) from 84.454%
12673986434

Pull #9385

github

web-flow
Merge 06e47f0ee into e475fd8fa
Pull Request #9385: refactor: Fix phpstan expr.resultUnused

20699 of 24509 relevant lines covered (84.45%)

190.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.31
/system/Router/AutoRouterImproved.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Router;
15

16
use CodeIgniter\Exceptions\PageNotFoundException;
17
use CodeIgniter\Router\Exceptions\MethodNotFoundException;
18
use Config\Routing;
19
use ReflectionClass;
20
use ReflectionException;
21

22
/**
23
 * New Secure Router for Auto-Routing
24
 *
25
 * @see \CodeIgniter\Router\AutoRouterImprovedTest
26
 */
27
final class AutoRouterImproved implements AutoRouterInterface
28
{
29
    /**
30
     * Sub-directory that contains the requested controller class.
31
     */
32
    private ?string $directory = null;
33

34
    /**
35
     * The name of the controller class.
36
     */
37
    private string $controller;
38

39
    /**
40
     * The name of the method to use.
41
     */
42
    private string $method;
43

44
    /**
45
     * An array of params to the controller method.
46
     *
47
     * @var list<string>
48
     */
49
    private array $params = [];
50

51
    /**
52
     *  Whether to translate dashes in URIs for controller/method to CamelCase.
53
     *  E.g., blog-controller -> BlogController
54
     */
55
    private readonly bool $translateUriToCamelCase;
56

57
    /**
58
     * The namespace for controllers.
59
     */
60
    private string $namespace;
61

62
    /**
63
     * Map of URI segments and namespaces.
64
     *
65
     * The key is the first URI segment. The value is the controller namespace.
66
     * E.g.,
67
     *   [
68
     *       'blog' => 'Acme\Blog\Controllers',
69
     *   ]
70
     *
71
     * @var array [ uri_segment => namespace ]
72
     */
73
    private array $moduleRoutes;
74

75
    /**
76
     * The URI segments.
77
     *
78
     * @var list<string>
79
     */
80
    private array $segments = [];
81

82
    /**
83
     * The position of the Controller in the URI segments.
84
     * Null for the default controller.
85
     */
86
    private ?int $controllerPos = null;
87

88
    /**
89
     * The position of the Method in the URI segments.
90
     * Null for the default method.
91
     */
92
    private ?int $methodPos = null;
93

94
    /**
95
     * The position of the first Parameter in the URI segments.
96
     * Null for the no parameters.
97
     */
98
    private ?int $paramPos = null;
99

100
    /**
101
     * The current URI
102
     */
103
    private ?string $uri = null;
104

105
    /**
106
     * @param list<class-string> $protectedControllers
107
     * @param string             $defaultController    Short classname
108
     */
109
    public function __construct(
110
        /**
111
         * List of controllers in Defined Routes that should not be accessed via this Auto-Routing.
112
         */
113
        private readonly array $protectedControllers,
114
        string $namespace,
115
        private readonly string $defaultController,
116
        /**
117
         * The name of the default method without HTTP verb prefix.
118
         */
119
        private readonly string $defaultMethod,
120
        /**
121
         * Whether dashes in URI's should be converted
122
         * to underscores when determining method names.
123
         */
124
        private readonly bool $translateURIDashes
125
    ) {
126
        $this->namespace = rtrim($namespace, '\\');
45✔
127

128
        $routingConfig                 = config(Routing::class);
45✔
129
        $this->moduleRoutes            = $routingConfig->moduleRoutes;
45✔
130
        $this->translateUriToCamelCase = $routingConfig->translateUriToCamelCase;
45✔
131

132
        // Set the default values
133
        $this->controller = $this->defaultController;
45✔
134
    }
135

136
    private function createSegments(string $uri): array
137
    {
138
        $segments = explode('/', $uri);
47✔
139
        $segments = array_filter($segments, static fn ($segment): bool => $segment !== '');
47✔
140

141
        // numerically reindex the array, removing gaps
142
        return array_values($segments);
47✔
143
    }
144

145
    /**
146
     * Search for the first controller corresponding to the URI segment.
147
     *
148
     * If there is a controller corresponding to the first segment, the search
149
     * ends there. The remaining segments are parameters to the controller.
150
     *
151
     * @return bool true if a controller class is found.
152
     */
153
    private function searchFirstController(): bool
154
    {
155
        $segments = $this->segments;
47✔
156

157
        $controller = '\\' . $this->namespace;
47✔
158

159
        $controllerPos = -1;
47✔
160

161
        while ($segments !== []) {
47✔
162
            $segment = array_shift($segments);
44✔
163
            $controllerPos++;
44✔
164

165
            $class = $this->translateURI($segment);
44✔
166

167
            // as soon as we encounter any segment that is not PSR-4 compliant, stop searching
168
            if (! $this->isValidSegment($class)) {
41✔
169
                return false;
5✔
170
            }
171

172
            $controller .= '\\' . $class;
38✔
173

174
            if (class_exists($controller)) {
38✔
175
                $this->controller    = $controller;
33✔
176
                $this->controllerPos = $controllerPos;
33✔
177

178
                $this->checkUriForController($controller);
33✔
179

180
                // The first item may be a method name.
181
                $this->params = $segments;
32✔
182
                if ($segments !== []) {
32✔
183
                    $this->paramPos = $this->controllerPos + 1;
28✔
184
                }
185

186
                return true;
32✔
187
            }
188
        }
189

190
        return false;
6✔
191
    }
192

193
    /**
194
     * Search for the last default controller corresponding to the URI segments.
195
     *
196
     * @return bool true if a controller class is found.
197
     */
198
    private function searchLastDefaultController(): bool
199
    {
200
        $segments = $this->segments;
11✔
201

202
        $segmentCount = count($this->segments);
11✔
203
        $paramPos     = null;
11✔
204
        $params       = [];
11✔
205

206
        while ($segments !== []) {
11✔
207
            if ($segmentCount > count($segments)) {
8✔
208
                $paramPos = count($segments);
2✔
209
            }
210

211
            $namespaces = array_map(
8✔
212
                fn ($segment): string => $this->translateURI($segment),
8✔
213
                $segments
8✔
214
            );
8✔
215

216
            $controller = '\\' . $this->namespace
8✔
217
                . '\\' . implode('\\', $namespaces)
8✔
218
                . '\\' . $this->defaultController;
8✔
219

220
            if (class_exists($controller)) {
8✔
221
                $this->controller = $controller;
5✔
222
                $this->params     = $params;
5✔
223

224
                if ($params !== []) {
5✔
225
                    $this->paramPos = $paramPos;
2✔
226
                }
227

228
                return true;
5✔
229
            }
230

231
            // Prepend the last element in $segments to the beginning of $params.
232
            array_unshift($params, array_pop($segments));
5✔
233
        }
234

235
        // Check for the default controller in Controllers directory.
236
        $controller = '\\' . $this->namespace
6✔
237
            . '\\' . $this->defaultController;
6✔
238

239
        if (class_exists($controller)) {
6✔
240
            $this->controller = $controller;
6✔
241
            $this->params     = $params;
6✔
242

243
            if ($params !== []) {
6✔
244
                $this->paramPos = 0;
3✔
245
            }
246

247
            return true;
6✔
248
        }
249

250
        return false;
×
251
    }
252

253
    /**
254
     * Finds controller, method and params from the URI.
255
     *
256
     * @param string $httpVerb HTTP verb like `GET`,`POST`
257
     *
258
     * @return array [directory_name, controller_name, controller_method, params]
259
     */
260
    public function getRoute(string $uri, string $httpVerb): array
261
    {
262
        $this->uri = $uri;
47✔
263
        $httpVerb  = strtolower($httpVerb);
47✔
264

265
        // Reset Controller method params.
266
        $this->params = [];
47✔
267

268
        $defaultMethod = $httpVerb . ucfirst($this->defaultMethod);
47✔
269
        $this->method  = $defaultMethod;
47✔
270

271
        $this->segments = $this->createSegments($uri);
47✔
272

273
        // Check for Module Routes.
274
        if (
275
            $this->segments !== []
47✔
276
            && array_key_exists($this->segments[0], $this->moduleRoutes)
47✔
277
        ) {
278
            $uriSegment      = array_shift($this->segments);
1✔
279
            $this->namespace = rtrim($this->moduleRoutes[$uriSegment], '\\');
1✔
280
        }
281

282
        if ($this->searchFirstController()) {
47✔
283
            // Controller is found.
284
            $baseControllerName = class_basename($this->controller);
32✔
285

286
            // Prevent access to default controller path
287
            if (
288
                strtolower($baseControllerName) === strtolower($this->defaultController)
32✔
289
            ) {
290
                throw new PageNotFoundException(
2✔
291
                    'Cannot access the default controller "' . $this->controller . '" with the controller name URI path.'
2✔
292
                );
2✔
293
            }
294
        } elseif ($this->searchLastDefaultController()) {
11✔
295
            // The default Controller is found.
296
            $baseControllerName = class_basename($this->controller);
11✔
297
        } else {
298
            // No Controller is found.
299
            throw new PageNotFoundException('No controller is found for: ' . $uri);
×
300
        }
301

302
        // The first item may be a method name.
303
        /** @var list<string> $params */
304
        $params = $this->params;
41✔
305

306
        $methodParam = array_shift($params);
41✔
307

308
        $method = '';
41✔
309
        if ($methodParam !== null) {
41✔
310
            $method = $httpVerb . $this->translateURI($methodParam);
32✔
311

312
            $this->checkUriForMethod($method);
30✔
313
        }
314

315
        if ($methodParam !== null && method_exists($this->controller, $method)) {
38✔
316
            // Method is found.
317
            $this->method = $method;
22✔
318
            $this->params = $params;
22✔
319

320
            // Update the positions.
321
            $this->methodPos = $this->paramPos;
22✔
322
            if ($params === []) {
22✔
323
                $this->paramPos = null;
16✔
324
            }
325
            if ($this->paramPos !== null) {
22✔
326
                $this->paramPos++;
9✔
327
            }
328

329
            // Prevent access to default controller's method
330
            if (strtolower($baseControllerName) === strtolower($this->defaultController)) {
22✔
331
                throw new PageNotFoundException(
×
332
                    'Cannot access the default controller "' . $this->controller . '::' . $this->method . '"'
×
333
                );
×
334
            }
335

336
            // Prevent access to default method path
337
            if (strtolower($this->method) === strtolower($defaultMethod)) {
22✔
338
                throw new PageNotFoundException(
1✔
339
                    'Cannot access the default method "' . $this->method . '" with the method name URI path.'
1✔
340
                );
1✔
341
            }
342
        } elseif (method_exists($this->controller, $defaultMethod)) {
20✔
343
            // The default method is found.
344
            $this->method = $defaultMethod;
20✔
345
        } else {
346
            // No method is found.
347
            throw PageNotFoundException::forControllerNotFound($this->controller, $method);
×
348
        }
349

350
        // Ensure the controller is not defined in routes.
351
        $this->protectDefinedRoutes();
37✔
352

353
        // Ensure the controller does not have _remap() method.
354
        $this->checkRemap();
37✔
355

356
        // Ensure the URI segments for the controller and method do not contain
357
        // underscores when $translateURIDashes is true.
358
        $this->checkUnderscore();
36✔
359

360
        // Check parameter count
361
        try {
362
            $this->checkParameters();
33✔
363
        } catch (MethodNotFoundException) {
4✔
364
            throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
×
365
        }
366

367
        $this->setDirectory();
29✔
368

369
        return [$this->directory, $this->controller, $this->method, $this->params];
29✔
370
    }
371

372
    /**
373
     * @internal For test purpose only.
374
     *
375
     * @return array<string, int|null>
376
     */
377
    public function getPos(): array
378
    {
379
        return [
13✔
380
            'controller' => $this->controllerPos,
13✔
381
            'method'     => $this->methodPos,
13✔
382
            'params'     => $this->paramPos,
13✔
383
        ];
13✔
384
    }
385

386
    /**
387
     * Get the directory path from the controller and set it to the property.
388
     *
389
     * @return void
390
     */
391
    private function setDirectory()
392
    {
393
        $segments = explode('\\', trim($this->controller, '\\'));
29✔
394

395
        // Remove short classname.
396
        array_pop($segments);
29✔
397

398
        $namespaces = implode('\\', $segments);
29✔
399

400
        $dir = str_replace(
29✔
401
            '\\',
29✔
402
            '/',
29✔
403
            ltrim(substr($namespaces, strlen($this->namespace)), '\\')
29✔
404
        );
29✔
405

406
        if ($dir !== '') {
29✔
407
            $this->directory = $dir . '/';
11✔
408
        }
409
    }
410

411
    private function protectDefinedRoutes(): void
412
    {
413
        $controller = strtolower($this->controller);
37✔
414

415
        foreach ($this->protectedControllers as $controllerInRoutes) {
37✔
416
            $routeLowerCase = strtolower($controllerInRoutes);
3✔
417

418
            if ($routeLowerCase === $controller) {
3✔
419
                throw new PageNotFoundException(
×
420
                    'Cannot access the controller in Defined Routes. Controller: ' . $controllerInRoutes
×
421
                );
×
422
            }
423
        }
424
    }
425

426
    private function checkParameters(): void
427
    {
428
        try {
429
            $refClass = new ReflectionClass($this->controller);
33✔
430
        } catch (ReflectionException) {
×
431
            throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
×
432
        }
433

434
        try {
435
            $refMethod = $refClass->getMethod($this->method);
33✔
436
            $refParams = $refMethod->getParameters();
33✔
437
        } catch (ReflectionException) {
×
438
            throw new MethodNotFoundException();
×
439
        }
440

441
        if (! $refMethod->isPublic()) {
33✔
442
            throw new MethodNotFoundException();
×
443
        }
444

445
        if (count($refParams) < count($this->params)) {
33✔
446
            throw new PageNotFoundException(
4✔
447
                'The param count in the URI are greater than the controller method params.'
4✔
448
                . ' Handler:' . $this->controller . '::' . $this->method
4✔
449
                . ', URI:' . $this->uri
4✔
450
            );
4✔
451
        }
452
    }
453

454
    private function checkRemap(): void
455
    {
456
        try {
457
            $refClass = new ReflectionClass($this->controller);
37✔
458
            $refClass->getMethod('_remap');
37✔
459

460
            throw new PageNotFoundException(
1✔
461
                'AutoRouterImproved does not support `_remap()` method.'
1✔
462
                . ' Controller:' . $this->controller
1✔
463
            );
1✔
464
        } catch (ReflectionException) {
37✔
465
            // Do nothing.
466
        }
467
    }
468

469
    private function checkUnderscore(): void
470
    {
471
        if ($this->translateURIDashes === false) {
36✔
472
            return;
6✔
473
        }
474

475
        $paramPos = $this->paramPos ?? count($this->segments);
30✔
476

477
        for ($i = 0; $i < $paramPos; $i++) {
30✔
478
            if (str_contains($this->segments[$i], '_')) {
24✔
479
                throw new PageNotFoundException(
3✔
480
                    'AutoRouterImproved prohibits access to the URI'
3✔
481
                    . ' containing underscores ("' . $this->segments[$i] . '")'
3✔
482
                    . ' when $translateURIDashes is enabled.'
3✔
483
                    . ' Please use the dash.'
3✔
484
                    . ' Handler:' . $this->controller . '::' . $this->method
3✔
485
                    . ', URI:' . $this->uri
3✔
486
                );
3✔
487
            }
488
        }
489
    }
490

491
    /**
492
     * Check URI for controller for $translateUriToCamelCase
493
     *
494
     * @param string $classname Controller classname that is generated from URI.
495
     *                          The case may be a bit incorrect.
496
     */
497
    private function checkUriForController(string $classname): void
498
    {
499
        if ($this->translateUriToCamelCase === false) {
33✔
500
            return;
5✔
501
        }
502

503
        if (! in_array(ltrim($classname, '\\'), get_declared_classes(), true)) {
28✔
504
            throw new PageNotFoundException(
1✔
505
                '"' . $classname . '" is not found.'
1✔
506
            );
1✔
507
        }
508
    }
509

510
    /**
511
     * Check URI for method for $translateUriToCamelCase
512
     *
513
     * @param string $method Controller method name that is generated from URI.
514
     *                       The case may be a bit incorrect.
515
     */
516
    private function checkUriForMethod(string $method): void
517
    {
518
        if ($this->translateUriToCamelCase === false) {
30✔
519
            return;
5✔
520
        }
521

522
        if (
523
            // For example, if `getSomeMethod()` exists in the controller, only
524
            // the URI `controller/some-method` should be accessible. But if a
525
            // visitor navigates to the URI `controller/somemethod`, `getSomemethod()`
526
            // will be checked, and `method_exists()` will return true because
527
            // method names in PHP are case-insensitive.
528
            method_exists($this->controller, $method)
25✔
529
            // But we do not permit `controller/somemethod`, so check the exact
530
            // method name.
531
            && ! in_array($method, get_class_methods($this->controller), true)
25✔
532
        ) {
533
            throw new PageNotFoundException(
1✔
534
                '"' . $this->controller . '::' . $method . '()" is not found.'
1✔
535
            );
1✔
536
        }
537
    }
538

539
    /**
540
     * Returns true if the supplied $segment string represents a valid PSR-4 compliant namespace/directory segment
541
     *
542
     * regex comes from https://www.php.net/manual/en/language.variables.basics.php
543
     */
544
    private function isValidSegment(string $segment): bool
545
    {
546
        return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment);
41✔
547
    }
548

549
    /**
550
     * Translates URI segment to CamelCase or replaces `-` with `_`.
551
     */
552
    private function translateURI(string $segment): string
553
    {
554
        if ($this->translateUriToCamelCase) {
44✔
555
            if (strtolower($segment) !== $segment) {
38✔
556
                throw new PageNotFoundException(
3✔
557
                    'AutoRouterImproved prohibits access to the URI'
3✔
558
                    . ' containing uppercase letters ("' . $segment . '")'
3✔
559
                    . ' when $translateUriToCamelCase is enabled.'
3✔
560
                    . ' Please use the dash.'
3✔
561
                    . ' URI:' . $this->uri
3✔
562
                );
3✔
563
            }
564

565
            if (str_contains($segment, '--')) {
36✔
566
                throw new PageNotFoundException(
2✔
567
                    'AutoRouterImproved prohibits access to the URI'
2✔
568
                    . ' containing double dash ("' . $segment . '")'
2✔
569
                    . ' when $translateUriToCamelCase is enabled.'
2✔
570
                    . ' Please use the single dash.'
2✔
571
                    . ' URI:' . $this->uri
2✔
572
                );
2✔
573
            }
574

575
            return str_replace(
35✔
576
                ' ',
35✔
577
                '',
35✔
578
                ucwords(
35✔
579
                    preg_replace('/[\-]+/', ' ', $segment)
35✔
580
                )
35✔
581
            );
35✔
582
        }
583

584
        $segment = ucfirst($segment);
6✔
585

586
        if ($this->translateURIDashes) {
6✔
587
            return str_replace('-', '_', $segment);
6✔
588
        }
589

590
        return $segment;
×
591
    }
592
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc