1<?php
2/**
3 * ZnoteX installer - shared runtime.
4 *
5 * The installer deliberately does NOT boot engine/init.php: at step 1 there is
6 * no database, no config, possibly no schema. It talks to mysqli directly and
7 * only loads the engine once there is something to load.
8 *
9 * It is also the most dangerous file in the project - it writes configuration
10 * and grants admin rights - so it refuses to run once the site is installed.
11 * See install_is_locked().
12 */
13
14if (!defined('ZNOTE_INSTALL')) {
15 http_response_code(403);
16 die('Direct access denied.');
17}
18
19/**
20 * Since PHP 8.1 mysqli throws on error instead of returning false, and "@" does
21 * not suppress an exception. The installer queries tables that are expected to
22 * be missing - that is the whole point of the checks - so it asks for the old
23 * behaviour explicitly rather than wrapping every call in try/catch.
24 */
25mysqli_report(MYSQLI_REPORT_OFF);
26
27const INSTALL_LOCK = 'installed.lock';
28const INSTALL_STEPS = array(
29 1 => 'Requirements',
30 2 => 'Database',
31 3 => 'Server',
32 4 => 'Schema',
33 5 => 'Administrator',
34 6 => 'Finish',
35);
36
37// ---------------------------------------------------------------------------
38// Paths
39// ---------------------------------------------------------------------------
40function install_root(): string {
41 return dirname(__DIR__);
42}
43
44function install_lock_file(): string {
45 return __DIR__ . '/' . INSTALL_LOCK;
46}
47
48function install_config_file(): string {
49 return install_root() . '/config.local.php';
50}
51
52// ---------------------------------------------------------------------------
53// The lock
54// ---------------------------------------------------------------------------
55
56/**
57 * Why two conditions rather than one:
58 *
59 * The lock file alone is not enough - someone restoring a backup or unpacking
60 * a fresh copy over an installed site would drop it and the installer would
61 * happily reset the admin account. The database is the second opinion: if the
62 * znote table already holds a row, this site is installed regardless of what
63 * the filesystem says.
64 *
65 * Returns '' when the installer may run, or a reason when it may not.
66 */
67function install_locked_reason(): string {
68 if (is_file(install_lock_file())) {
69 return 'This site is already installed. Delete <code>install/' . INSTALL_LOCK
70 . '</code> if you really mean to run the installer again.';
71 }
72
73 // A wizard already in progress must not be locked out by its own work:
74 // step 4 creates the znote table, which is exactly what the check below
75 // looks for. Step 2 has already warned if the database was not empty.
76 if (!empty($_SESSION['install']) || install_max_step() > 1) {
77 return '';
78 }
79
80 $config = install_saved_config();
81 if (!$config) {
82 return '';
83 }
84
85 $link = @new mysqli($config['sqlHost'], $config['sqlUser'], $config['sqlPassword'], $config['sqlDatabase']);
86 if ($link->connect_errno) {
87 return '';
88 }
89
90 $result = @$link->query('SELECT `id` FROM `znote` LIMIT 1');
91 $rows = ($result !== false) ? $result->num_rows : 0;
92 $link->close();
93
94 if ($rows > 0) {
95 return 'The database already contains a ZnoteX installation. The installer will not'
96 . ' overwrite it. Delete the <code>znote</code> table first if that is really what you want.';
97 }
98
99 return '';
100}
101
102// ---------------------------------------------------------------------------
103// Wizard state
104//
105// Kept in the session, so a refresh does not lose the credentials typed two
106// steps ago. Nothing is written to disk until the final step.
107// ---------------------------------------------------------------------------
108function install_state(?array $merge = null): array {
109 if (!isset($_SESSION['install'])) {
110 $_SESSION['install'] = array();
111 }
112
113 if ($merge !== null) {
114 $_SESSION['install'] = array_merge($_SESSION['install'], $merge);
115 }
116
117 return $_SESSION['install'];
118}
119
120function install_get(string $key, $default = '') {
121 $state = install_state();
122 return $state[$key] ?? $default;
123}
124
125function install_reset(): void {
126 unset($_SESSION['install']);
127}
128
129/** Highest step reached, so someone cannot skip ahead by editing the URL. */
130function install_max_step(?int $reached = null): int {
131 if ($reached !== null && $reached > (int)($_SESSION['install_max'] ?? 1)) {
132 $_SESSION['install_max'] = $reached;
133 }
134
135 return (int)($_SESSION['install_max'] ?? 1);
136}
137
138// ---------------------------------------------------------------------------
139// Config
140// ---------------------------------------------------------------------------
141
142/** Read config.local.php if it exists, else fall back to config.php values. */
143function install_saved_config(): array {
144 $keys = array('sqlHost', 'sqlUser', 'sqlPassword', 'sqlDatabase');
145 $out = array();
146
147 foreach (array(install_config_file(), install_root() . '/config.php') as $file) {
148 if (!is_file($file)) {
149 continue;
150 }
151
152 $config = array();
153 // Included in a function so it cannot pollute anything.
154 @include $file;
155
156 foreach ($keys as $key) {
157 if (!isset($out[$key]) && isset($config[$key])) {
158 $out[$key] = (string)$config[$key];
159 }
160 }
161 }
162
163 return (count($out) === count($keys)) ? $out : array();
164}
165
166/** A connection using the values collected so far, or null. */
167function install_connect(?string &$error = null): ?mysqli {
168 $link = @new mysqli(
169 (string)install_get('sqlHost', '127.0.0.1'),
170 (string)install_get('sqlUser'),
171 (string)install_get('sqlPassword'),
172 (string)install_get('sqlDatabase')
173 );
174
175 if ($link->connect_errno) {
176 $error = $link->connect_error;
177 return null;
178 }
179
180 $link->set_charset('utf8mb4');
181 $link->query("SET collation_connection = 'utf8mb4_general_ci'");
182
183 return $link;
184}
185
186// ---------------------------------------------------------------------------
187// Checks
188// ---------------------------------------------------------------------------
189
190/** Requirements, as [label, ok, detail, fatal]. */
191function install_requirements(): array {
192 $checks = array();
193
194 $checks[] = array(
195 'PHP 8.1 or newer',
196 PHP_VERSION_ID >= 80100,
197 'You are on PHP ' . PHP_VERSION,
198 true,
199 );
200
201 foreach (array('mysqli' => true, 'curl' => false, 'openssl' => false, 'gd' => false, 'zip' => false) as $ext => $fatal) {
202 $checks[] = array(
203 'Extension: ' . $ext,
204 extension_loaded($ext),
205 $fatal ? 'Required' : 'Optional',
206 $fatal,
207 );
208 }
209
210 $cache = install_root() . '/engine/cache';
211 $checks[] = array(
212 'engine/cache/ is writable',
213 is_dir($cache) && is_writable($cache),
214 $cache,
215 true,
216 );
217
218 $checks[] = array(
219 'The site root is writable',
220 is_writable(install_root()),
221 'Needed to write config.local.php. You can also create it by hand at the last step.',
222 false,
223 );
224
225 $schema = install_root() . '/SQL/znote_schema.sql';
226 $checks[] = array(
227 'SQL/znote_schema.sql is present',
228 is_file($schema),
229 $schema,
230 true,
231 );
232
233 return $checks;
234}
235
236/**
237 * Tables the OT server creates, which ZnoteX reads but never creates itself.
238 * Their absence is what makes the installer refuse to go on.
239 */
240function install_server_tables(mysqli $link): array {
241 $required = array('accounts', 'players');
242 $optional = array('guilds', 'houses', 'player_deaths', 'players_online');
243
244 $present = array();
245 $result = @$link->query('SHOW TABLES');
246 if ($result !== false) {
247 while ($row = $result->fetch_array()) {
248 $present[strtolower($row[0])] = true;
249 }
250 }
251
252 $out = array('required' => array(), 'optional' => array(), 'ok' => true);
253
254 foreach ($required as $table) {
255 $found = isset($present[$table]);
256 $out['required'][$table] = $found;
257 if (!$found) {
258 $out['ok'] = false;
259 }
260 }
261 foreach ($optional as $table) {
262 $out['optional'][$table] = isset($present[$table]);
263 }
264
265 $out['znote_installed'] = isset($present['znote']);
266
267 return $out;
268}
269
270// ---------------------------------------------------------------------------
271// Small view helpers
272// ---------------------------------------------------------------------------
273function ih($value): string {
274 return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
275}
276
277function install_url(int $step): string {
278 return 'index.php?step=' . $step;
279}
280
281function install_error(string $message): void {
282 $_SESSION['install_error'] = $message;
283}
284
285function install_take_error(): string {
286 $error = (string)($_SESSION['install_error'] ?? '');
287 unset($_SESSION['install_error']);
288 return $error;
289}
290
291function install_csrf_token(): string {
292 if (empty($_SESSION['install_csrf']) || !is_string($_SESSION['install_csrf'])) {
293 $_SESSION['install_csrf'] = bin2hex(random_bytes(32));
294 }
295
296 return $_SESSION['install_csrf'];
297}
298
299function install_csrf_field(): string {
300 return '<input type="hidden" name="install_csrf" value="' . ih(install_csrf_token()) . '">';
301}
302
303function install_csrf_validate(): bool {
304 $posted = $_POST['install_csrf'] ?? null;
305 $token = $_SESSION['install_csrf'] ?? null;
306
307 if (!is_string($posted) || $posted === '' || !is_string($token) || $token === '') {
308 return false;
309 }
310
311 $valid = hash_equals($token, $posted);
312 if ($valid) {
313 $_SESSION['install_csrf'] = bin2hex(random_bytes(32));
314 }
315
316 return $valid;
317}
318
319function install_csrf_inject(string $html): string {
320 if (stripos($html, '<form') === false || stripos($html, 'method') === false) {
321 return $html;
322 }
323
324 return preg_replace_callback(
325 '~<form\b(?=[^>]*\bmethod\s*=\s*["\']?post["\']?)[^>]*>~i',
326 static function (array $match): string {
327 return $match[0] . "\n" . install_csrf_field();
328 },
329 $html
330 ) ?? $html;
331}
332