Lines 85.36% 35 / 41
Methods 71.42% 5 / 7
Classes 0.00% 0 / 1
Covered by tests of size
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
37readonly 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}