Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.80% covered (success)
87.80%
36 / 41
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateToken
87.80% covered (success)
87.80%
36 / 41
71.43% covered (warning)
71.43%
5 / 7
25.04
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTokenFilePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOrCreate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isValid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 create
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 read
87.50% covered (success)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
9.16
1<?php
2
3/**
4 * The UpdateToken class manages the one-time secret that authorizes the update
5 * wizard when no administrator session is available.
6 *
7 * This Source Code Form is subject to the terms of the Mozilla Public License,
8 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
9 * obtain one at https://mozilla.org/MPL/2.0/.
10 *
11 * @package   phpMyFAQ
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @copyright 2026 phpMyFAQ Team
14 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
15 * @link      https://www.phpmyfaq.de
16 * @since     2026-08-02
17 */
18
19declare(strict_types=1);
20
21namespace phpMyFAQ\Setup;
22
23use phpMyFAQ\Core\Exception;
24use Random\RandomException;
25
26/**
27 * A major update leaves the installation in a state where a login can be impossible:
28 * the new code already expects columns the old database does not have yet, so an
29 * administrator upgrading from an older release cannot authenticate before the
30 * migration has run. The update endpoints therefore accept a second proof of
31 * authorization: a secret that is written to the configuration directory and can
32 * only be read by someone with access to the file system of the server.
33 *
34 * The token file is a PHP file that exits immediately, so it stays unreadable over
35 * HTTP even on servers without the shipped deny rules for the content directory.
36 */
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 . 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        $payload = json_decode(substr($content, strlen(self::FILE_HEADER)), associative: true);
142        if (!is_array($payload)) {
143            return null;
144        }
145
146        $token = $payload['token'] ?? null;
147        $created = $payload['created'] ?? null;
148
149        if (!is_string($token) || $token === '' || !is_int($created)) {
150            return null;
151        }
152
153        if (($created + self::TOKEN_LIFETIME) < time()) {
154            return null;
155        }
156
157        return $token;
158    }
159}