Lines
85.36%
35 / 41
Methods
71.42%
5 / 7
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| getTokenFilePath | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| getOrCreate | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| isValid | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 4 | ||
| delete | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| create | 75.00% | 9 / 12 | 0.00% | 0 / 1 | 5.39 | ||
| read | 81.25% | 13 / 16 | 0.00% | 0 / 1 | 11.80 | ||
| 37 | readonly class UpdateToken | |
| 38 | { | |
| 39 | public const string TOKEN_FILENAME = 'update-token.php'; | |
| 40 | ||
| 41 | /** Lifetime of a token in seconds. */ | |
| 42 | public const int TOKEN_LIFETIME = 3600; | |
| 43 | ||
| 44 | private const string FILE_HEADER = '<?php exit; ?>'; | |
| 45 | ||
| 46 | public function __construct( | |
| 47 | private string $configDir, | |
| 48 | ) { | |
| 49 | } | |
| 50 | ||
| 51 | public function getTokenFilePath(): string | |
| 52 | { | |
| 53 | return $this->configDir . DIRECTORY_SEPARATOR . self::TOKEN_FILENAME; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Returns the current token and creates a new one if there is none or if the | |
| 58 | * existing one has expired. | |
| 59 | * | |
| 60 | * @throws Exception | |
| 61 | */ | |
| 62 | public function getOrCreate(): string | |
| 63 | { | |
| 64 | $token = $this->read(); | |
| 65 | if (is_string($token)) { | |
| 66 | return $token; | |
| 67 | } | |
| 68 | ||
| 69 | return $this->create(); | |
| 70 | } | |
| 71 | ||
| 72 | /** | |
| 73 | * Returns true if the given token matches the stored, non-expired token. | |
| 74 | */ | |
| 75 | public function isValid(#[\SensitiveParameter] ?string $token): bool | |
| 76 | { | |
| 77 | if (!is_string($token) || $token === '') { | |
| 78 | return false; | |
| 79 | } | |
| 80 | ||
| 81 | $storedToken = $this->read(); | |
| 82 | ||
| 83 | return is_string($storedToken) && hash_equals($storedToken, $token); | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Removes the token file, e.g. once the update has been applied. | |
| 88 | */ | |
| 89 | public function delete(): void | |
| 90 | { | |
| 91 | $tokenFilePath = $this->getTokenFilePath(); | |
| 92 | if (is_file($tokenFilePath)) { | |
| 93 | unlink($tokenFilePath); | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * Creates and stores a new token. | |
| 99 | * | |
| 100 | * @throws Exception | |
| 101 | */ | |
| 102 | private function create(): string | |
| 103 | { | |
| 104 | if (!is_dir($this->configDir) || !is_writable($this->configDir)) { | |
| 105 | throw new Exception(sprintf( | |
| 106 | 'Cannot create the update token, the directory %s is not writable.', | |
| 107 | $this->configDir, | |
| 108 | )); | |
| 109 | } | |
| 110 | ||
| 111 | try { | |
| 112 | $token = bin2hex(random_bytes(16)); | |
| 113 | } catch (RandomException $randomException) { | |
| 114 | throw new Exception('Cannot create the update token: ' . $randomException->getMessage()); | |
| 115 | } | |
| 116 | ||
| 117 | $content = self::FILE_HEADER . PHP_EOL . (string) json_encode(['token' => $token, 'created' => time()]); | |
| 118 | ||
| 119 | if (file_put_contents($this->getTokenFilePath(), $content) === false) { | |
| 120 | throw new Exception(sprintf('Cannot write the update token to %s.', $this->getTokenFilePath())); | |
| 121 | } | |
| 122 | ||
| 123 | return $token; | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * Returns the stored token, or null if there is none or if it has expired. | |
| 128 | */ | |
| 129 | private function read(): ?string | |
| 130 | { | |
| 131 | $tokenFilePath = $this->getTokenFilePath(); | |
| 132 | if (!is_file($tokenFilePath)) { | |
| 133 | return null; | |
| 134 | } | |
| 135 | ||
| 136 | $content = file_get_contents($tokenFilePath); | |
| 137 | if ($content === false || !str_starts_with($content, self::FILE_HEADER)) { | |
| 138 | return null; | |
| 139 | } | |
| 140 | ||
| 141 | /** @var array<array-key, mixed>|bool|float|int|string|null $payload */ | |
| 142 | $payload = json_decode(substr($content, strlen(self::FILE_HEADER)), associative: true); | |
| 143 | if (!is_array($payload)) { | |
| 144 | return null; | |
| 145 | } | |
| 146 | ||
| 147 | if (!array_key_exists('token', $payload) || !is_string($payload['token']) || $payload['token'] === '') { | |
| 148 | return null; | |
| 149 | } | |
| 150 | ||
| 151 | if (!array_key_exists('created', $payload) || !is_int($payload['created'])) { | |
| 152 | return null; | |
| 153 | } | |
| 154 | ||
| 155 | if (($payload['created'] + self::TOKEN_LIFETIME) < time()) { | |
| 156 | return null; | |
| 157 | } | |
| 158 | ||
| 159 | return $payload['token']; | |
| 160 | } | |
| 161 | } |