Skip to content

Commit 9c861e0

Browse files
committed
Update PHPStan baseline with error messages and paths for identifiers
1 parent df15d6f commit 9c861e0

15 files changed

Lines changed: 1311 additions & 572 deletions

phpstan-baseline.neon

Lines changed: 98 additions & 254 deletions
Large diffs are not rendered by default.

src/Cache/RouteCache.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
/**
4+
* RouteCache.php
5+
*
6+
* This file is part of Router.
7+
*
8+
* @author Muhammet ŞAFAK <info@muhammetsafak.com.tr>
9+
* @copyright Copyright © 2022 Muhammet ŞAFAK
10+
* @license ./LICENSE MIT
11+
* @link https://www.muhammetsafak.com.tr
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
namespace InitPHP\Router\Cache;
17+
18+
use InitPHP\Router\Exception\RouterException;
19+
20+
/**
21+
* Serializes the compiled route table to a file and reads it back within a
22+
* configurable TTL, so route definitions do not have to be rebuilt on every
23+
* request.
24+
*/
25+
final class RouteCache
26+
{
27+
private bool $enabled;
28+
29+
private ?string $path;
30+
31+
private int $ttl;
32+
33+
/**
34+
* @param array<string, mixed> $config Keys: enable (bool), path (string), ttl (int seconds).
35+
*/
36+
public function __construct(array $config = [])
37+
{
38+
$this->enabled = (bool) ($config['enable'] ?? false);
39+
$this->path = (isset($config['path']) && \is_string($config['path']) && $config['path'] !== '')
40+
? $config['path']
41+
: null;
42+
$this->ttl = (int) ($config['ttl'] ?? 86400);
43+
}
44+
45+
public function isEnabled(): bool
46+
{
47+
return $this->enabled && $this->path !== null;
48+
}
49+
50+
/**
51+
* Loads the cached route state, or null when caching is disabled, the file
52+
* is missing/unreadable as a cache entry, or the entry has expired.
53+
*
54+
* @return array<string, mixed>|null
55+
* @throws RouterException When the cache file exists but cannot be read.
56+
*/
57+
public function load(): ?array
58+
{
59+
if (!$this->enabled || $this->path === null || !\is_file($this->path)) {
60+
return null;
61+
}
62+
if (($read = @\file_get_contents($this->path)) === false) {
63+
throw new RouterException('Cannot read router cache file (' . $this->path . ')');
64+
}
65+
66+
$data = @\unserialize($read, ['allowed_classes' => false]);
67+
if (!\is_array($data) || !isset($data['created_at'], $data['data']) || !\is_array($data['data'])) {
68+
return null;
69+
}
70+
if (((int) $data['created_at'] + $this->ttl) < \time()) {
71+
return null;
72+
}
73+
74+
return $data['data'];
75+
}
76+
77+
/**
78+
* Persists the given route state.
79+
*
80+
* Never throws — it may run during object destruction, where exceptions are
81+
* fatal. A failed write emits an E_USER_WARNING and returns false.
82+
*
83+
* @param array<string, mixed> $state
84+
*/
85+
public function save(array $state): bool
86+
{
87+
if (!$this->enabled || $this->path === null) {
88+
return false;
89+
}
90+
91+
$payload = \serialize(['created_at' => \time(), 'data' => $state]);
92+
if (@\file_put_contents($this->path, $payload) === false) {
93+
\trigger_error('Failed to create router cache file (' . $this->path . ').', \E_USER_WARNING);
94+
95+
return false;
96+
}
97+
98+
return true;
99+
}
100+
}

src/Http/RequestContext.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/**
4+
* RequestContext.php
5+
*
6+
* This file is part of Router.
7+
*
8+
* @author Muhammet ŞAFAK <info@muhammetsafak.com.tr>
9+
* @copyright Copyright © 2022 Muhammet ŞAFAK
10+
* @license ./LICENSE MIT
11+
* @link https://www.muhammetsafak.com.tr
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
namespace InitPHP\Router\Http;
17+
18+
use Psr\Http\Message\RequestInterface;
19+
20+
/**
21+
* Derives the request facts the router routes on — the (optionally overridden)
22+
* HTTP method and the client IP address — from the PSR-7 request and the
23+
* server/request parameter bags.
24+
*
25+
* The parameter bags are injectable so the detection logic is testable without
26+
* touching superglobals.
27+
*/
28+
final class RequestContext
29+
{
30+
private string $method;
31+
32+
private ?string $clientIp;
33+
34+
/**
35+
* @param list<string> $supportedMethods Methods accepted for "_method" override.
36+
* @param array<string, mixed>|null $serverParams Defaults to $_SERVER.
37+
* @param array<string, mixed>|null $requestParams Defaults to $_REQUEST.
38+
*/
39+
public function __construct(
40+
RequestInterface $request,
41+
bool $variableMethod = false,
42+
array $supportedMethods = [],
43+
?array $serverParams = null,
44+
?array $requestParams = null
45+
) {
46+
$serverParams ??= $_SERVER;
47+
$requestParams ??= $_REQUEST;
48+
49+
$this->method = $this->detectMethod($request, $variableMethod, $supportedMethods, $requestParams);
50+
$this->clientIp = $this->detectClientIp($serverParams);
51+
}
52+
53+
public function getMethod(): string
54+
{
55+
return $this->method;
56+
}
57+
58+
public function getClientIp(): ?string
59+
{
60+
return $this->clientIp;
61+
}
62+
63+
/**
64+
* @param list<string> $supportedMethods
65+
* @param array<string, mixed> $requestParams
66+
*/
67+
private function detectMethod(
68+
RequestInterface $request,
69+
bool $variableMethod,
70+
array $supportedMethods,
71+
array $requestParams
72+
): string {
73+
$method = \strtoupper($request->getMethod());
74+
if (!$variableMethod || !isset($requestParams['_method'])) {
75+
return $method;
76+
}
77+
$override = \strtoupper((string) $requestParams['_method']);
78+
79+
return \in_array($override, $supportedMethods, true) ? $override : $method;
80+
}
81+
82+
/**
83+
* @param array<string, mixed> $serverParams
84+
*/
85+
private function detectClientIp(array $serverParams): ?string
86+
{
87+
if (!empty($serverParams['HTTP_CLIENT_IP'])) {
88+
return (string) $serverParams['HTTP_CLIENT_IP'];
89+
}
90+
if (!empty($serverParams['HTTP_X_FORWARDED_FOR'])) {
91+
$forwarded = (string) $serverParams['HTTP_X_FORWARDED_FOR'];
92+
if (\str_contains($forwarded, ',')) {
93+
$forwarded = \trim(\explode(',', $forwarded, 2)[0]);
94+
}
95+
96+
return $forwarded;
97+
}
98+
99+
return isset($serverParams['REMOTE_ADDR']) ? (string) $serverParams['REMOTE_ADDR'] : null;
100+
}
101+
}

src/Link/FileLinkHandler.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/**
4+
* FileLinkHandler.php
5+
*
6+
* This file is part of Router.
7+
*
8+
* @author Muhammet ŞAFAK <info@muhammetsafak.com.tr>
9+
* @copyright Copyright © 2022 Muhammet ŞAFAK
10+
* @license ./LICENSE MIT
11+
* @link https://www.muhammetsafak.com.tr
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
namespace InitPHP\Router\Link;
17+
18+
use Psr\Http\Message\ResponseInterface;
19+
20+
/**
21+
* Resolves and serves the files behind LINK routes.
22+
*
23+
* Directory links are protected against path traversal: the resolved real
24+
* path must stay inside the link root. Files are streamed into the PSR-7
25+
* response body in chunks so large files do not require loading the whole
26+
* file into memory and never bypass the response pipeline.
27+
*/
28+
final class FileLinkHandler
29+
{
30+
private const CHUNK_SIZE = 8192;
31+
32+
/**
33+
* Resolves the real file a link should serve, or null when there is
34+
* nothing safe to serve (missing file, or a directory-link request that
35+
* escapes the link root).
36+
*
37+
* @param array<int, mixed> $arguments The matched route arguments.
38+
*/
39+
public function resolve(string $source, array $arguments): ?string
40+
{
41+
if (\is_dir($source)) {
42+
if (($root = \realpath($source)) === false) {
43+
return null;
44+
}
45+
$suffix = isset($arguments[0]) ? \ltrim((string) $arguments[0], '/\\') : '';
46+
$target = \realpath($root . \DIRECTORY_SEPARATOR . $suffix);
47+
if ($target === false || !\is_file($target)) {
48+
return null;
49+
}
50+
if ($target !== $root && \strncmp($target, $root . \DIRECTORY_SEPARATOR, \strlen($root) + 1) !== 0) {
51+
return null;
52+
}
53+
54+
return $target;
55+
}
56+
57+
return \is_file($source) ? $source : null;
58+
}
59+
60+
/**
61+
* Streams the linked file into the response, returning the new response,
62+
* or null when there is nothing safe to serve.
63+
*
64+
* @param array<int, mixed> $arguments
65+
*/
66+
public function serve(string $source, array $arguments, ResponseInterface $response): ?ResponseInterface
67+
{
68+
$path = $this->resolve($source, $arguments);
69+
if ($path === null) {
70+
return null;
71+
}
72+
73+
$size = (int) \filesize($path);
74+
$mime = (string) \mime_content_type($path);
75+
76+
$body = $response->getBody();
77+
if ($body->isWritable() && ($handle = @\fopen($path, 'rb')) !== false) {
78+
while (!\feof($handle)) {
79+
$chunk = \fread($handle, self::CHUNK_SIZE);
80+
if ($chunk === false) {
81+
break;
82+
}
83+
$body->write($chunk);
84+
}
85+
\fclose($handle);
86+
}
87+
88+
return $response
89+
->withHeader('Content-Type', $mime)
90+
->withHeader('Content-Length', (string) $size);
91+
}
92+
}

src/Matching/PatternRegistry.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
/**
4+
* PatternRegistry.php
5+
*
6+
* This file is part of Router.
7+
*
8+
* @author Muhammet ŞAFAK <info@muhammetsafak.com.tr>
9+
* @copyright Copyright © 2022 Muhammet ŞAFAK
10+
* @license ./LICENSE MIT
11+
* @link https://www.muhammetsafak.com.tr
12+
*/
13+
14+
declare(strict_types=1);
15+
16+
namespace InitPHP\Router\Matching;
17+
18+
/**
19+
* Holds the named route-parameter patterns and compiles them into the
20+
* preg search/replace pairs used to turn a route path into a regular
21+
* expression.
22+
*/
23+
final class PatternRegistry
24+
{
25+
/** @var array<string, string> */
26+
private array $patterns = [
27+
'{[^/]+}' => '([^/]+)',
28+
':any[0-9]?' => '([^/]+)',
29+
'{any[0-9]?}' => '([^/]+)',
30+
':id[0-9]?' => '(\d+)',
31+
'{id[0-9]?}' => '(\d+)',
32+
':int[0-9]?' => '(\d+)',
33+
'{int[0-9]?}' => '(\d+)',
34+
':number[0-9]?' => '([+-]?([0-9]*[.])?[0-9]+)',
35+
'{number[0-9]?}' => '([+-]?([0-9]*[.])?[0-9]+)',
36+
':float[0-9]?' => '([+-]?([0-9]*[.])?[0-9]+)',
37+
'{float[0-9]?}' => '([+-]?([0-9]*[.])?[0-9]+)',
38+
':bool[0-9]?' => '(true|false|1|0)',
39+
'{bool[0-9]?}' => '(true|false|1|0)',
40+
':string[0-9]?' => '([\w\-_]+)',
41+
'{string[0-9]?}' => '([\w\-_]+)',
42+
':slug[0-9]?' => '([\w\-_]+)',
43+
'{slug[0-9]?}' => '([\w\-_]+)',
44+
':uuid[0-9]?' => '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
45+
'{uuid[0-9]?}' => '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
46+
':date[0-9]?' => '([0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]))',
47+
'{date[0-9]?}' => '([0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1]))',
48+
':locale' => '([A-Za-z]{2}|[A-Za-z]{2}[\_\-]{1}[A-Za-z]{2})',
49+
'{locale}' => '([A-Za-z]{2}|[A-Za-z]{2}[\_\-]{1}[A-Za-z]{2})',
50+
':everything' => '(.*)',
51+
'{everything}' => '(.*)',
52+
];
53+
54+
/**
55+
* Registers (or overrides) a named pattern.
56+
*
57+
* The key is normalised (surrounding ':' / '{}' are stripped) and the
58+
* pattern is wrapped in a capturing group when it is not already one.
59+
*/
60+
public function add(string $key, string $pattern): void
61+
{
62+
$key = \trim($key, ':{}');
63+
if (\substr($pattern, 0, 1) !== '(' && \substr($pattern, -1) !== ')') {
64+
$pattern = '(' . $pattern . ')';
65+
}
66+
$this->patterns[':' . $key] = $pattern;
67+
$this->patterns['{' . $key . '}'] = $pattern;
68+
}
69+
70+
/**
71+
* Compiles the registered patterns into preg search/replace pairs.
72+
*
73+
* A trailing catch-all entry maps any remaining "{name}" placeholder to a
74+
* slug-like capturing group.
75+
*
76+
* @return array{keys: list<string>, values: list<string>}
77+
*/
78+
public function compiled(): array
79+
{
80+
$res = ['keys' => [], 'values' => []];
81+
foreach ($this->patterns as $key => $value) {
82+
$res['keys'][] = '#' . $key . '#';
83+
$res['values'][] = $value;
84+
}
85+
$res['keys'][] = '#{[A-Za-z0-9]+}#';
86+
$res['values'][] = '([\w\-_]+)';
87+
88+
return $res;
89+
}
90+
}

0 commit comments

Comments
 (0)