updater.php

main 1117 lines · 41.9 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2
3const ZNOTE_UPDATE_SCHEMA = 1;
4const ZNOTE_UPDATE_REPOSITORY = 'Open-Games-Community/ZnoteX';
5const ZNOTE_UPDATE_MAX_BYTES = 268435456;
6
7function znote_update_root(): string
8{
9 return dirname(__DIR__, 2);
10}
11
12function znote_update_storage(): string
13{
14 return dirname(__DIR__) . '/update';
15}
16
17function znote_update_current_version(): string
18{
19 return (string)require dirname(__DIR__) . '/version.php';
20}
21
22function znote_update_api_url(): string
23{
24 return 'https://api.github.com/repos/' . ZNOTE_UPDATE_REPOSITORY . '/releases/latest';
25}
26
27function znote_update_public_key(): string
28{
29 $file = dirname(__DIR__) . '/update-public.pem';
30 return is_file($file) ? (string)file_get_contents($file) : '';
31}
32
33function znote_update_prepare_storage(): bool
34{
35 $directories = array(
36 znote_update_storage(),
37 znote_update_storage() . '/downloads',
38 znote_update_storage() . '/staging',
39 znote_update_storage() . '/backups',
40 );
41
42 foreach ($directories as $directory) {
43 if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
44 return false;
45 }
46 }
47
48 $deny = znote_update_storage() . '/.htaccess';
49 if (!is_file($deny)) {
50 file_put_contents($deny, "Require all denied\nDeny from all\n");
51 }
52
53 return true;
54}
55
56function znote_update_url_allowed(string $url): bool
57{
58 $parts = parse_url($url);
59 if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https') {
60 return false;
61 }
62
63 $host = strtolower((string)($parts['host'] ?? ''));
64 return in_array($host, array('api.github.com', 'github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'), true);
65}
66
67function znote_update_http(string $url, int $maxBytes = 4194304): array
68{
69 if (!znote_update_url_allowed($url)) {
70 return array('ok' => false, 'error' => 'Untrusted download address.');
71 }
72
73 if (!function_exists('curl_init')) {
74 return array('ok' => false, 'error' => 'The PHP cURL extension is required.');
75 }
76
77 $handle = curl_init($url);
78 $data = '';
79 $tooLarge = false;
80 $untrustedRedirect = false;
81 $options = array(
82 CURLOPT_FOLLOWLOCATION => true,
83 CURLOPT_MAXREDIRS => 5,
84 CURLOPT_CONNECTTIMEOUT => 10,
85 CURLOPT_TIMEOUT => 90,
86 CURLOPT_SSL_VERIFYPEER => true,
87 CURLOPT_SSL_VERIFYHOST => 2,
88 CURLOPT_USERAGENT => 'ZnoteX-Updater/' . znote_update_current_version(),
89 CURLOPT_HTTPHEADER => array('Accept: application/vnd.github+json', 'X-GitHub-Api-Version: 2022-11-28'),
90 CURLOPT_HEADERFUNCTION => static function ($curl, string $header) use (&$untrustedRedirect): int {
91 if (preg_match('/^Location:\s*(\S+)/i', trim($header), $match) && str_contains($match[1], '://') && !znote_update_url_allowed($match[1])) {
92 $untrustedRedirect = true;
93 return 0;
94 }
95 return strlen($header);
96 },
97 CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$data, &$tooLarge, $maxBytes): int {
98 if (strlen($data) + strlen($chunk) > $maxBytes) {
99 $tooLarge = true;
100 return 0;
101 }
102 $data .= $chunk;
103 return strlen($chunk);
104 },
105 );
106 if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTPS')) {
107 $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTPS;
108 $options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTPS;
109 }
110 if (function_exists('znote_cainfo')) {
111 $cainfo = znote_cainfo();
112 if ($cainfo !== '') {
113 $options[CURLOPT_CAINFO] = $cainfo;
114 }
115 }
116 curl_setopt_array($handle, $options);
117
118 $success = curl_exec($handle);
119 $status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
120 $error = curl_error($handle);
121 curl_close($handle);
122
123 if ($tooLarge) {
124 return array('ok' => false, 'error' => 'The downloaded file exceeds the allowed size.');
125 }
126 if ($untrustedRedirect) {
127 return array('ok' => false, 'error' => 'GitHub returned an untrusted redirect address.');
128 }
129 if ($success === false || $status < 200 || $status >= 300) {
130 return array('ok' => false, 'error' => $error !== '' ? $error : 'HTTP error ' . $status . '.');
131 }
132
133 return array('ok' => true, 'data' => $data);
134}
135
136function znote_update_asset(array $release, string $name): ?array
137{
138 foreach (($release['assets'] ?? array()) as $asset) {
139 if (is_array($asset) && (string)($asset['name'] ?? '') === $name) {
140 return $asset;
141 }
142 }
143 return null;
144}
145
146function znote_update_verify_manifest(string $json, string $signature): array
147{
148 $key = znote_update_public_key();
149 if ($key === '' || !function_exists('openssl_verify')) {
150 return array('ok' => false, 'error' => 'The update signature verifier is unavailable.');
151 }
152
153 $decodedSignature = base64_decode(trim($signature), true);
154 if ($decodedSignature === false || openssl_verify($json, $decodedSignature, $key, OPENSSL_ALGO_SHA256) !== 1) {
155 return array('ok' => false, 'error' => 'The update.json signature is invalid.');
156 }
157
158 $manifest = json_decode($json, true);
159 if (!is_array($manifest) || (int)($manifest['schema'] ?? 0) !== ZNOTE_UPDATE_SCHEMA) {
160 return array('ok' => false, 'error' => 'The update manifest is invalid or unsupported.');
161 }
162
163 if (!znote_update_manifest_shape_valid($manifest)) {
164 return array('ok' => false, 'error' => 'The signed manifest has missing or invalid fields.');
165 }
166
167 if (!znote_update_manifest_files_valid($manifest['files'])) {
168 return array('ok' => false, 'error' => 'The signed file list is invalid.');
169 }
170
171 return array('ok' => true, 'manifest' => $manifest);
172}
173
174function znote_update_manifest_shape_valid(array $manifest): bool
175{
176 $version = trim((string)($manifest['version'] ?? ''));
177 $package = $manifest['package'] ?? null;
178 $files = $manifest['files'] ?? null;
179
180 return preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version) === 1
181 && is_array($package)
182 && preg_match('/^[A-Za-z0-9._-]+\.zip$/', (string)($package['name'] ?? '')) === 1
183 && preg_match('/^[a-f0-9]{64}$/', (string)($package['sha256'] ?? '')) === 1
184 && is_array($files)
185 && $files !== array();
186}
187
188function znote_update_manifest_files_valid(array $files): bool
189{
190 foreach ($files as $path => $hash) {
191 if (!is_string($path) || znote_update_path($path) !== $path || !preg_match('/^[a-f0-9]{64}$/', (string)$hash)) {
192 return false;
193 }
194 }
195 return true;
196}
197
198function znote_update_path(string $path): string
199{
200 $path = str_replace('\\', '/', trim($path));
201 $path = trim($path, '/');
202 $parts = explode('/', $path);
203 if ($path === '' || preg_match('/[\x00-\x1F\x7F:*?"<>|]/', $path)) {
204 return '';
205 }
206 foreach ($parts as $part) {
207 if ($part === '' || $part === '.' || $part === '..') {
208 return '';
209 }
210 }
211 return implode('/', $parts);
212}
213
214function znote_update_protected(string $path): bool
215{
216 $path = strtolower(znote_update_path($path));
217 foreach (array('config.php', 'config.local.php', 'plugins/', 'layouts/', 'engine/cache/', 'engine/img/theme/', 'engine/update/', 'install/', 'release/', '.git/', '.github/') as $protected) {
218 if ($path === rtrim($protected, '/') || str_starts_with($path, $protected)) {
219 return true;
220 }
221 }
222 return false;
223}
224
225function znote_update_latest(bool $refresh = false): array
226{
227 if (!znote_update_prepare_storage()) {
228 return array('ok' => false, 'error' => 'The update storage directory cannot be created.');
229 }
230
231 $cacheFile = znote_update_storage() . '/latest.json';
232 if (!$refresh) {
233 $cached = znote_update_latest_cached($cacheFile);
234 if ($cached !== null) {
235 return $cached;
236 }
237 }
238
239 $release = znote_update_fetch_release();
240 if (!$release['ok']) {
241 return $release;
242 }
243
244 $verified = znote_update_fetch_manifest($release['release']);
245 if (!$verified['ok']) {
246 return $verified;
247 }
248
249 $result = znote_update_build_latest_result($release['release'], $verified['manifest']);
250 if (!$result['ok']) {
251 return $result;
252 }
253
254 file_put_contents($cacheFile, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
255 return $result;
256}
257
258function znote_update_latest_cached(string $cacheFile): ?array
259{
260 if (!is_file($cacheFile) || filemtime($cacheFile) < time() - 900) {
261 return null;
262 }
263 $cached = json_decode((string)file_get_contents($cacheFile), true);
264 return is_array($cached) ? $cached : null;
265}
266
267function znote_update_fetch_release(): array
268{
269 $response = znote_update_http(znote_update_api_url());
270 if (!$response['ok']) {
271 return $response;
272 }
273 $release = json_decode($response['data'], true);
274 if (!is_array($release) || !empty($release['draft']) || !empty($release['prerelease'])) {
275 return array('ok' => false, 'error' => 'GitHub did not return a stable release.');
276 }
277 return array('ok' => true, 'release' => $release);
278}
279
280function znote_update_fetch_manifest(array $release): array
281{
282 $jsonAsset = znote_update_asset($release, 'update.json');
283 $signatureAsset = znote_update_asset($release, 'update.json.sig');
284 if ($jsonAsset === null || $signatureAsset === null) {
285 return array('ok' => false, 'error' => 'The release is missing update.json or update.json.sig.');
286 }
287
288 $jsonResponse = znote_update_http((string)($jsonAsset['browser_download_url'] ?? ''));
289 $signatureResponse = znote_update_http((string)($signatureAsset['browser_download_url'] ?? ''), 65536);
290 if (!$jsonResponse['ok']) {
291 return $jsonResponse;
292 }
293 if (!$signatureResponse['ok']) {
294 return $signatureResponse;
295 }
296
297 return znote_update_verify_manifest($jsonResponse['data'], $signatureResponse['data']);
298}
299
300function znote_update_build_latest_result(array $release, array $manifest): array
301{
302 $tag = ltrim((string)($release['tag_name'] ?? ''), 'vV');
303 if ($tag !== (string)$manifest['version']) {
304 return array('ok' => false, 'error' => 'The GitHub tag does not match the signed version.');
305 }
306
307 $packageAsset = znote_update_asset($release, (string)$manifest['package']['name']);
308 if ($packageAsset === null) {
309 return array('ok' => false, 'error' => 'The signed ZIP package is missing from the release.');
310 }
311
312 return array(
313 'ok' => true,
314 'available' => version_compare((string)$manifest['version'], znote_update_current_version(), '>'),
315 'current' => znote_update_current_version(),
316 'manifest' => $manifest,
317 'package_url' => (string)($packageAsset['browser_download_url'] ?? ''),
318 'release_url' => (string)($release['html_url'] ?? ''),
319 );
320}
321
322function znote_update_check(string $label, bool $ok, string $detail): array
323{
324 return array('label' => $label, 'ok' => $ok, 'detail' => $detail);
325}
326
327function znote_update_remove_tree(string $path): void
328{
329 $base = realpath(znote_update_storage());
330 $target = realpath($path);
331 if ($base === false || $target === false || $target === $base || !str_starts_with(strtolower($target), strtolower($base . DIRECTORY_SEPARATOR))) {
332 return;
333 }
334 $iterator = new RecursiveIteratorIterator(
335 new RecursiveDirectoryIterator($target, FilesystemIterator::SKIP_DOTS),
336 RecursiveIteratorIterator::CHILD_FIRST
337 );
338 foreach ($iterator as $item) {
339 if ($item->isDir() && !$item->isLink()) {
340 rmdir($item->getPathname());
341 } else {
342 unlink($item->getPathname());
343 }
344 }
345 rmdir($target);
346}
347
348function znote_update_download_package(array $latest): array
349{
350 $manifest = $latest['manifest'];
351 $name = (string)$manifest['package']['name'];
352 $file = znote_update_storage() . '/downloads/' . $name;
353 if (is_file($file) && hash_file('sha256', $file) === (string)$manifest['package']['sha256']) {
354 return array('ok' => true, 'file' => $file);
355 }
356
357 $response = znote_update_http((string)$latest['package_url'], ZNOTE_UPDATE_MAX_BYTES);
358 if (!$response['ok']) {
359 return $response;
360 }
361 if (hash('sha256', $response['data']) !== (string)$manifest['package']['sha256']) {
362 return array('ok' => false, 'error' => 'The ZIP checksum does not match the signed manifest.');
363 }
364 if (file_put_contents($file, $response['data'], LOCK_EX) === false) {
365 return array('ok' => false, 'error' => 'The ZIP package cannot be saved.');
366 }
367 return array('ok' => true, 'file' => $file);
368}
369
370function znote_update_extract(string $zipFile, array $files, string $destination): array
371{
372 if (!class_exists('ZipArchive')) {
373 return array('ok' => false, 'error' => 'The PHP Zip extension is required.');
374 }
375 if (is_dir($destination)) {
376 znote_update_remove_tree($destination);
377 }
378 if (!mkdir($destination, 0750, true) && !is_dir($destination)) {
379 return array('ok' => false, 'error' => 'The staging directory cannot be created.');
380 }
381
382 $zip = new ZipArchive();
383 if ($zip->open($zipFile) !== true) {
384 return array('ok' => false, 'error' => 'The ZIP package cannot be opened.');
385 }
386 $seen = array();
387 for ($index = 0; $index < $zip->numFiles; $index++) {
388 $raw = (string)$zip->getNameIndex($index);
389 if (str_ends_with(str_replace('\\', '/', $raw), '/')) {
390 continue;
391 }
392 $entry = znote_update_extract_entry($zip, $index, $raw, $files, $seen, $destination);
393 if (!$entry['ok']) {
394 $zip->close();
395 return $entry;
396 }
397 $seen[$entry['path']] = true;
398 }
399 $zip->close();
400
401 if (count($seen) !== count($files)) {
402 return array('ok' => false, 'error' => 'The ZIP does not contain every signed file.');
403 }
404 return array('ok' => true, 'directory' => $destination);
405}
406
407function znote_update_extract_entry(ZipArchive $zip, int $index, string $raw, array $files, array $seen, string $destination): array
408{
409 $path = znote_update_path($raw);
410 if ($path === '' || znote_update_protected($path) || !array_key_exists($path, $files) || isset($seen[$path])) {
411 return array('ok' => false, 'error' => 'The ZIP contains an unexpected or protected file: ' . $raw);
412 }
413
414 $contents = $zip->getFromIndex($index);
415 if (!is_string($contents) || hash('sha256', $contents) !== (string)$files[$path]) {
416 return array('ok' => false, 'error' => 'A packaged file failed checksum validation: ' . $path);
417 }
418
419 $target = $destination . '/' . $path;
420 $directory = dirname($target);
421 if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
422 return array('ok' => false, 'error' => 'A staging directory cannot be created.');
423 }
424 if (file_put_contents($target, $contents, LOCK_EX) === false) {
425 return array('ok' => false, 'error' => 'A staged file cannot be written: ' . $path);
426 }
427
428 return array('ok' => true, 'path' => $path);
429}
430
431function znote_update_migration_safe(string $sql): bool
432{
433 $clean = preg_replace('~/\*.*?\*/~s', ' ', $sql) ?? $sql;
434 $clean = preg_replace('/^\s*--.*$/m', ' ', $clean) ?? $clean;
435 if (preg_match('/\b(DROP|TRUNCATE|RENAME|CHANGE|MODIFY|DELETE|UPDATE|REPLACE)\b/i', $clean)) {
436 return false;
437 }
438 foreach (array_filter(array_map('trim', explode(';', $clean))) as $statement) {
439 if (!preg_match('/^(CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS|ALTER\s+TABLE\s+[^\s]+\s+ADD\b|INSERT\s+IGNORE\b)/i', $statement)) {
440 return false;
441 }
442 }
443 return true;
444}
445
446function znote_update_check_version(string $version): array
447{
448 $newer = version_compare($version, znote_update_current_version(), '>');
449 return znote_update_check('Version', $newer, $newer ? 'Version ' . $version . ' is newer than ' . znote_update_current_version() . '.' : 'No newer stable version is available.');
450}
451
452function znote_update_check_php_version(array $requires): array
453{
454 $phpConstraint = (string)($requires['php'] ?? '>=8.1');
455 $phpOk = function_exists('znote_extension_version_matches') && znote_extension_version_matches(PHP_VERSION, $phpConstraint);
456 return znote_update_check('PHP version', $phpOk, 'Required ' . $phpConstraint . '; running ' . PHP_VERSION . '.');
457}
458
459function znote_update_check_extensions(array $requires): array
460{
461 $requiredExtensions = is_array($requires['extensions'] ?? null) ? $requires['extensions'] : array('curl', 'json', 'openssl', 'zip');
462 $checks = array();
463 foreach ($requiredExtensions as $extension) {
464 $name = strtolower(trim((string)$extension));
465 $loaded = $name !== '' && extension_loaded($name);
466 $checks[] = znote_update_check('PHP extension: ' . $name, $loaded, $loaded ? 'Loaded.' : 'Missing from this PHP installation.');
467 }
468 return $checks;
469}
470
471function znote_update_check_disk_space(array $manifest): array
472{
473 $free = @disk_free_space(znote_update_root());
474 $needed = max(52428800, (int)($manifest['package']['size'] ?? 0) * 3);
475 $diskOk = is_float($free) && $free >= $needed;
476 return znote_update_check('Disk space', $diskOk, $diskOk ? 'At least ' . number_format($needed / 1048576, 0) . ' MB is available.' : 'At least ' . number_format($needed / 1048576, 0) . ' MB of free space is required.');
477}
478
479function znote_update_check_migrations(array $manifest, string $stage): array
480{
481 $migrations = is_array($manifest['migrations'] ?? null) ? $manifest['migrations'] : array();
482 if ($migrations === array()) {
483 return znote_update_check('Database migrations', true, 'No database migration is required.');
484 }
485
486 foreach ($migrations as $migration) {
487 $path = is_array($migration) ? znote_update_path((string)($migration['file'] ?? '')) : '';
488 $type = is_array($migration) ? (string)($migration['type'] ?? '') : '';
489 $file = $stage . '/' . $path;
490 if ($type !== 'expand' || $path === '' || !is_file($file) || !znote_update_migration_safe((string)file_get_contents($file))) {
491 return znote_update_check('Database migrations', false, 'A migration is missing, destructive or not marked as expand-only.');
492 }
493 }
494 return znote_update_check('Database migrations', true, count($migrations) . ' additive migration(s) validated.');
495}
496
497function znote_update_check_local_modifications(): array
498{
499 $conflicts = array();
500 $installedFile = znote_update_storage() . '/installed.json';
501 if (is_file($installedFile)) {
502 $installed = json_decode((string)file_get_contents($installedFile), true);
503 foreach (($installed['files'] ?? array()) as $path => $hash) {
504 $target = znote_update_root() . '/' . znote_update_path((string)$path);
505 if (is_file($target) && hash_file('sha256', $target) !== (string)$hash) {
506 $conflicts[] = (string)$path;
507 }
508 }
509 }
510 return znote_update_check('Local modifications', $conflicts === array(), $conflicts === array() ? 'No locally modified managed file will be overwritten.' : 'Modified files would be overwritten: ' . implode(', ', array_slice($conflicts, 0, 8)));
511}
512
513function znote_update_checks_pass(array $checks): bool
514{
515 foreach ($checks as $check) {
516 if (!$check['ok']) {
517 return false;
518 }
519 }
520 return true;
521}
522
523function znote_update_preflight(array $latest): array
524{
525 if (empty($latest['ok'])) {
526 return array('ok' => false, 'checks' => array(znote_update_check('Release metadata', false, (string)($latest['error'] ?? 'Unknown error.'))));
527 }
528
529 $manifest = $latest['manifest'];
530 $version = (string)$manifest['version'];
531 $requires = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : array();
532
533 $checks = array();
534 $checks[] = znote_update_check_version($version);
535 $checks[] = znote_update_check('Digital signature', true, 'update.json has a valid ZnoteX RSA/SHA-256 signature.');
536 $checks[] = znote_update_check_php_version($requires);
537 foreach (znote_update_check_extensions($requires) as $check) {
538 $checks[] = $check;
539 }
540
541 $storageOk = znote_update_prepare_storage() && is_writable(znote_update_storage());
542 $rootOk = is_writable(znote_update_root());
543 $checks[] = znote_update_check('Update storage', $storageOk, $storageOk ? 'Writable.' : 'engine/update is not writable.');
544 $checks[] = znote_update_check('Website files', $rootOk, $rootOk ? 'The website root is writable.' : 'The website root is not writable by PHP.');
545 $checks[] = znote_update_check_disk_space($manifest);
546
547 $download = $storageOk ? znote_update_download_package($latest) : array('ok' => false, 'error' => 'Storage is unavailable.');
548 $checks[] = znote_update_check('ZIP checksum', !empty($download['ok']), !empty($download['ok']) ? 'The downloaded package matches its signed SHA-256 checksum.' : (string)($download['error'] ?? 'Download failed.'));
549
550 $stage = znote_update_storage() . '/staging/' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version);
551 $extracted = !empty($download['ok']) ? znote_update_extract((string)$download['file'], $manifest['files'], $stage) : array('ok' => false, 'error' => 'The ZIP was not validated.');
552 $checks[] = znote_update_check('Package contents', !empty($extracted['ok']), !empty($extracted['ok']) ? count($manifest['files']) . ' signed files validated; no protected or unexpected file found.' : (string)($extracted['error'] ?? 'Validation failed.'));
553
554 $checks[] = znote_update_check_migrations($manifest, $stage);
555 $checks[] = znote_update_check_local_modifications();
556
557 return array('ok' => znote_update_checks_pass($checks), 'checks' => $checks, 'stage' => $stage, 'manifest' => $manifest);
558}
559
560function znote_update_check_state_file(): string
561{
562 return znote_update_storage() . '/check_progress.json';
563}
564
565function znote_update_check_state_load(): ?array
566{
567 $file = znote_update_check_state_file();
568 if (!is_file($file)) {
569 return null;
570 }
571 $data = json_decode((string)file_get_contents($file), true);
572 return is_array($data) ? $data : null;
573}
574
575function znote_update_check_state_save(array $state): void
576{
577 file_put_contents(znote_update_check_state_file(), json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
578}
579
580function znote_update_check_state_clear(): void
581{
582 $file = znote_update_check_state_file();
583 if (is_file($file)) {
584 unlink($file);
585 }
586}
587
588function znote_update_check_finish(array $checks, array $manifest): array
589{
590 znote_update_check_state_clear();
591 return array('ok' => znote_update_checks_pass($checks), 'phase' => 'done', 'checks' => $checks, 'manifest' => $manifest);
592}
593
594function znote_update_check_start(array $latest): array
595{
596 if (empty($latest['ok'])) {
597 return znote_update_check_finish(array(znote_update_check('Release metadata', false, (string)($latest['error'] ?? 'Unknown error.'))), array());
598 }
599
600 $manifest = $latest['manifest'];
601 $version = (string)$manifest['version'];
602 $requires = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : array();
603
604 $checks = array();
605 $checks[] = znote_update_check_version($version);
606 $checks[] = znote_update_check('Digital signature', true, 'update.json has a valid ZnoteX RSA/SHA-256 signature.');
607 $checks[] = znote_update_check_php_version($requires);
608 foreach (znote_update_check_extensions($requires) as $check) {
609 $checks[] = $check;
610 }
611
612 $storageOk = znote_update_prepare_storage() && is_writable(znote_update_storage());
613 $rootOk = is_writable(znote_update_root());
614 $checks[] = znote_update_check('Update storage', $storageOk, $storageOk ? 'Writable.' : 'engine/update is not writable.');
615 $checks[] = znote_update_check('Website files', $rootOk, $rootOk ? 'The website root is writable.' : 'The website root is not writable by PHP.');
616 $checks[] = znote_update_check_disk_space($manifest);
617
618 $download = $storageOk ? znote_update_download_package($latest) : array('ok' => false, 'error' => 'Storage is unavailable.');
619 $checks[] = znote_update_check('ZIP checksum', !empty($download['ok']), !empty($download['ok']) ? 'The downloaded package matches its signed SHA-256 checksum.' : (string)($download['error'] ?? 'Download failed.'));
620
621 $stage = znote_update_storage() . '/staging/' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version);
622
623 if (empty($download['ok']) || !class_exists('ZipArchive')) {
624 $checks[] = znote_update_check('Package contents', false, !empty($download['ok']) ? 'The PHP Zip extension is required.' : 'The ZIP was not validated.');
625 return znote_update_check_finish($checks, $manifest);
626 }
627
628 if (is_dir($stage)) {
629 znote_update_remove_tree($stage);
630 }
631 if (!mkdir($stage, 0750, true) && !is_dir($stage)) {
632 $checks[] = znote_update_check('Package contents', false, 'The staging directory cannot be created.');
633 return znote_update_check_finish($checks, $manifest);
634 }
635
636 $zip = new ZipArchive();
637 if ($zip->open($download['file']) !== true) {
638 $checks[] = znote_update_check('Package contents', false, 'The ZIP package cannot be opened.');
639 return znote_update_check_finish($checks, $manifest);
640 }
641 $total = $zip->numFiles;
642 $zip->close();
643
644 znote_update_check_state_save(array(
645 'phase' => 'extract',
646 'cursor' => 0,
647 'seen' => array(),
648 'zip_file' => $download['file'],
649 'stage' => $stage,
650 'manifest' => $manifest,
651 'checks' => $checks,
652 ));
653
654 return array('ok' => true, 'phase' => 'extract', 'cursor' => 0, 'total' => $total, 'message' => 'Extracting package...');
655}
656
657function znote_update_check_step_extract(array $state, int $batchSize): array
658{
659 $zip = new ZipArchive();
660 if ($zip->open($state['zip_file']) !== true) {
661 $state['checks'][] = znote_update_check('Package contents', false, 'The ZIP package cannot be opened.');
662 return znote_update_check_finish($state['checks'], $state['manifest']);
663 }
664
665 $total = $zip->numFiles;
666 $end = min($total, $state['cursor'] + $batchSize);
667 for ($index = $state['cursor']; $index < $end; $index++) {
668 $raw = (string)$zip->getNameIndex($index);
669 if (str_ends_with(str_replace('\\', '/', $raw), '/')) {
670 continue;
671 }
672 $entry = znote_update_extract_entry($zip, $index, $raw, $state['manifest']['files'], $state['seen'], $state['stage']);
673 if (!$entry['ok']) {
674 $zip->close();
675 $state['checks'][] = znote_update_check('Package contents', false, $entry['error']);
676 return znote_update_check_finish($state['checks'], $state['manifest']);
677 }
678 $state['seen'][$entry['path']] = true;
679 }
680 $zip->close();
681
682 $state['cursor'] = $end;
683 if ($state['cursor'] < $total) {
684 znote_update_check_state_save($state);
685 return array('ok' => true, 'phase' => 'extract', 'cursor' => $state['cursor'], 'total' => $total, 'message' => 'Extracting package (' . $state['cursor'] . '/' . $total . ')...');
686 }
687
688 $validated = count($state['seen']) === count($state['manifest']['files']);
689 $state['checks'][] = znote_update_check('Package contents', $validated, $validated ? count($state['manifest']['files']) . ' signed files validated; no protected or unexpected file found.' : 'The ZIP does not contain every signed file.');
690 $state['checks'][] = znote_update_check_migrations($state['manifest'], $state['stage']);
691
692 $tracked = array();
693 $installedFile = znote_update_storage() . '/installed.json';
694 if (is_file($installedFile)) {
695 $installed = json_decode((string)file_get_contents($installedFile), true);
696 $tracked = is_array($installed['files'] ?? null) ? $installed['files'] : array();
697 }
698 if ($tracked === array()) {
699 $state['checks'][] = znote_update_check('Local modifications', true, 'No locally modified managed file will be overwritten.');
700 return znote_update_check_finish($state['checks'], $state['manifest']);
701 }
702
703 $state['phase'] = 'localmods';
704 $state['cursor'] = 0;
705 $state['tracked'] = $tracked;
706 $state['tracked_paths'] = array_keys($tracked);
707 $state['conflicts'] = array();
708 znote_update_check_state_save($state);
709 return array('ok' => true, 'phase' => 'localmods', 'cursor' => 0, 'total' => count($state['tracked_paths']), 'message' => 'Package validated. Checking for local modifications...');
710}
711
712function znote_update_check_step_localmods(array $state, int $batchSize): array
713{
714 $total = count($state['tracked_paths']);
715 foreach (array_slice($state['tracked_paths'], $state['cursor'], $batchSize) as $path) {
716 $target = znote_update_root() . '/' . znote_update_path((string)$path);
717 if (is_file($target) && hash_file('sha256', $target) !== (string)$state['tracked'][$path]) {
718 $state['conflicts'][] = (string)$path;
719 }
720 }
721 $state['cursor'] = min($total, $state['cursor'] + $batchSize);
722 if ($state['cursor'] < $total) {
723 znote_update_check_state_save($state);
724 return array('ok' => true, 'phase' => 'localmods', 'cursor' => $state['cursor'], 'total' => $total, 'message' => 'Checking local files (' . $state['cursor'] . '/' . $total . ')...');
725 }
726
727 $conflicts = $state['conflicts'];
728 $state['checks'][] = znote_update_check('Local modifications', $conflicts === array(), $conflicts === array() ? 'No locally modified managed file will be overwritten.' : 'Modified files would be overwritten: ' . implode(', ', array_slice($conflicts, 0, 8)));
729 return znote_update_check_finish($state['checks'], $state['manifest']);
730}
731
732function znote_update_check_step(int $batchSize = 60): array
733{
734 $state = znote_update_check_state_load();
735 if ($state === null) {
736 return array('ok' => false, 'error' => 'No check is in progress.');
737 }
738
739 switch ($state['phase']) {
740 case 'extract': return znote_update_check_step_extract($state, $batchSize);
741 case 'localmods': return znote_update_check_step_localmods($state, $batchSize);
742 }
743
744 znote_update_check_state_clear();
745 return array('ok' => false, 'error' => 'Unknown check phase.');
746}
747
748function znote_update_backup(array $files, string $version): array
749{
750 $id = gmdate('Ymd-His') . '-from-' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version);
751 $directory = znote_update_storage() . '/backups/' . $id;
752 if (!mkdir($directory, 0750, true) && !is_dir($directory)) {
753 return array('ok' => false, 'error' => 'The backup directory cannot be created.');
754 }
755 $journal = array('id' => $id, 'version' => $version, 'created_at' => gmdate(DATE_ATOM), 'files' => array());
756 foreach (array_keys($files) as $path) {
757 $source = znote_update_root() . '/' . $path;
758 $journal['files'][$path] = is_file($source);
759 if (!is_file($source)) {
760 continue;
761 }
762 $target = $directory . '/files/' . $path;
763 if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0750, true) && !is_dir(dirname($target))) {
764 return array('ok' => false, 'error' => 'A backup directory cannot be created.');
765 }
766 if (!copy($source, $target)) {
767 return array('ok' => false, 'error' => 'A managed file cannot be backed up: ' . $path);
768 }
769 }
770 file_put_contents($directory . '/backup.json', json_encode($journal, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
771 return array('ok' => true, 'id' => $id, 'directory' => $directory, 'journal' => $journal);
772}
773
774function znote_update_restore_backup(array $backup): array
775{
776 $directory = (string)($backup['directory'] ?? '');
777 $journal = $backup['journal'] ?? null;
778 if (!is_array($journal) || !is_dir($directory)) {
779 return array('ok' => false, 'error' => 'The backup is invalid.');
780 }
781 foreach (($journal['files'] ?? array()) as $path => $existed) {
782 $path = znote_update_path((string)$path);
783 if ($path === '' || znote_update_protected($path)) {
784 return array('ok' => false, 'error' => 'The backup contains an invalid path.');
785 }
786 $target = znote_update_root() . '/' . $path;
787 if ($existed) {
788 $source = $directory . '/files/' . $path;
789 if (!is_file($source)) {
790 return array('ok' => false, 'error' => 'A backup file is missing: ' . $path);
791 }
792 if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0755, true) && !is_dir(dirname($target))) {
793 return array('ok' => false, 'error' => 'A destination directory cannot be restored.');
794 }
795 if (!copy($source, $target)) {
796 return array('ok' => false, 'error' => 'A file cannot be restored: ' . $path);
797 }
798 } elseif (is_file($target)) {
799 unlink($target);
800 }
801 }
802 return array('ok' => true);
803}
804
805function znote_update_apply_migrations(array $manifest, string $stage): array
806{
807 $migrations = is_array($manifest['migrations'] ?? null) ? $manifest['migrations'] : array();
808 if ($migrations === array()) {
809 return array('ok' => true);
810 }
811
812 db()->execute('CREATE TABLE IF NOT EXISTS znote_migrations (migration VARCHAR(191) NOT NULL PRIMARY KEY, checksum CHAR(64) NOT NULL, executed_at INT UNSIGNED NOT NULL, execution_time_ms INT UNSIGNED NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
813 foreach ($migrations as $migration) {
814 $path = znote_update_path((string)($migration['file'] ?? ''));
815 $file = $stage . '/' . $path;
816 $sql = is_file($file) ? (string)file_get_contents($file) : '';
817 $checksum = hash('sha256', $sql);
818 $found = db()->fetchOne('SELECT checksum FROM znote_migrations WHERE migration = ?', array($path));
819 if (is_array($found)) {
820 if ((string)($found['checksum'] ?? '') !== $checksum) {
821 return array('ok' => false, 'error' => 'An applied migration has changed: ' . $path);
822 }
823 continue;
824 }
825 $started = microtime(true);
826 foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) {
827 if (!db()->execute($statement)) {
828 return array('ok' => false, 'error' => 'Database migration failed: ' . $path);
829 }
830 }
831 $milliseconds = (int)round((microtime(true) - $started) * 1000);
832 db()->execute('INSERT INTO znote_migrations (migration, checksum, executed_at, execution_time_ms) VALUES (?, ?, ?, ?)', array($path, $checksum, time(), $milliseconds));
833 }
834 return array('ok' => true);
835}
836
837function znote_update_copy_files(array $files, string $stage): array
838{
839 foreach ($files as $path => $hash) {
840 $source = $stage . '/' . $path;
841 $target = znote_update_root() . '/' . $path;
842 $directory = dirname($target);
843 if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
844 return array('ok' => false, 'error' => 'A destination directory cannot be created: ' . $path);
845 }
846 $temp = $target . '.znote-update-' . bin2hex(random_bytes(6));
847 if (!copy($source, $temp) || hash_file('sha256', $temp) !== $hash) {
848 if (is_file($temp)) {
849 unlink($temp);
850 }
851 return array('ok' => false, 'error' => 'A file cannot be prepared: ' . $path);
852 }
853 if (!rename($temp, $target)) {
854 if (!copy($temp, $target)) {
855 unlink($temp);
856 return array('ok' => false, 'error' => 'A file cannot be installed: ' . $path);
857 }
858 unlink($temp);
859 }
860 if (hash_file('sha256', $target) !== $hash) {
861 return array('ok' => false, 'error' => 'An installed file failed checksum validation: ' . $path);
862 }
863 }
864 return array('ok' => true);
865}
866
867function znote_update_install(array $latest): array
868{
869 @set_time_limit(0);
870 @ini_set('max_execution_time', '0');
871
872 $preflight = znote_update_preflight($latest);
873 if (!$preflight['ok']) {
874 return array('ok' => false, 'error' => 'The pre-installation check is not fully green.', 'checks' => $preflight['checks']);
875 }
876
877 $manifest = $preflight['manifest'];
878 $backup = znote_update_backup($manifest['files'], znote_update_current_version());
879 if (!$backup['ok']) {
880 return $backup;
881 }
882
883 $migrations = znote_update_apply_migrations($manifest, $preflight['stage']);
884 if (!$migrations['ok']) {
885 return $migrations;
886 }
887
888 $lock = znote_update_storage() . '/maintenance.lock';
889 file_put_contents($lock, json_encode(array('version' => $manifest['version'], 'started_at' => gmdate(DATE_ATOM))));
890 try {
891 $copied = znote_update_copy_files($manifest['files'], $preflight['stage']);
892 if (!$copied['ok']) {
893 $restored = znote_update_restore_backup($backup);
894 return array('ok' => false, 'error' => $copied['error'] . ($restored['ok'] ? ' The file backup was restored.' : ' Automatic restoration failed: ' . $restored['error']));
895 }
896 $installed = array(
897 'version' => (string)$manifest['version'],
898 'installed_at' => gmdate(DATE_ATOM),
899 'backup' => (string)$backup['id'],
900 'files' => $manifest['files'],
901 );
902 file_put_contents(znote_update_storage() . '/installed.json', json_encode($installed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
903 if (function_exists('znote_cache_flush')) {
904 znote_cache_flush();
905 }
906 return array('ok' => true, 'version' => (string)$manifest['version'], 'backup' => (string)$backup['id']);
907 } finally {
908 if (is_file($lock)) {
909 unlink($lock);
910 }
911 }
912}
913
914function znote_update_backups(): array
915{
916 $result = array();
917 foreach (glob(znote_update_storage() . '/backups/*/backup.json') ?: array() as $file) {
918 $journal = json_decode((string)file_get_contents($file), true);
919 if (is_array($journal)) {
920 $result[] = array('directory' => dirname($file), 'journal' => $journal);
921 }
922 }
923 usort($result, static fn(array $a, array $b): int => strcmp((string)$b['journal']['created_at'], (string)$a['journal']['created_at']));
924 return $result;
925}
926
927function znote_update_progress_file(): string
928{
929 return znote_update_storage() . '/progress.json';
930}
931
932function znote_update_progress_load(): ?array
933{
934 $file = znote_update_progress_file();
935 if (!is_file($file)) {
936 return null;
937 }
938 $data = json_decode((string)file_get_contents($file), true);
939 return is_array($data) ? $data : null;
940}
941
942function znote_update_progress_save(array $progress): void
943{
944 file_put_contents(znote_update_progress_file(), json_encode($progress, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
945}
946
947function znote_update_install_cleanup(): void
948{
949 $file = znote_update_progress_file();
950 if (is_file($file)) {
951 unlink($file);
952 }
953 $lock = znote_update_storage() . '/maintenance.lock';
954 if (is_file($lock)) {
955 unlink($lock);
956 }
957}
958
959function znote_update_install_start(array $latest): array
960{
961 $preflight = znote_update_preflight($latest);
962 if (!$preflight['ok']) {
963 return array('ok' => false, 'error' => 'The pre-installation check is not fully green.', 'checks' => $preflight['checks']);
964 }
965
966 $manifest = $preflight['manifest'];
967 $paths = array_keys($manifest['files']);
968
969 $id = gmdate('Ymd-His') . '-from-' . preg_replace('/[^0-9A-Za-z._-]/', '-', znote_update_current_version());
970 $backupDir = znote_update_storage() . '/backups/' . $id;
971 if (!mkdir($backupDir, 0750, true) && !is_dir($backupDir)) {
972 return array('ok' => false, 'error' => 'The backup directory cannot be created.');
973 }
974
975 znote_update_progress_save(array(
976 'phase' => 'backup',
977 'cursor' => 0,
978 'paths' => $paths,
979 'total' => count($paths),
980 'stage' => $preflight['stage'],
981 'manifest' => $manifest,
982 'backup_id' => $id,
983 'backup_dir' => $backupDir,
984 'backup_journal' => array('id' => $id, 'version' => znote_update_current_version(), 'created_at' => gmdate(DATE_ATOM), 'files' => array()),
985 ));
986
987 $lock = znote_update_storage() . '/maintenance.lock';
988 file_put_contents($lock, json_encode(array('version' => $manifest['version'], 'started_at' => gmdate(DATE_ATOM))));
989
990 return array('ok' => true, 'phase' => 'backup', 'cursor' => 0, 'total' => count($paths), 'message' => 'Starting backup...');
991}
992
993function znote_update_install_step_backup(array $progress, int $batchSize): array
994{
995 foreach (array_slice($progress['paths'], $progress['cursor'], $batchSize) as $path) {
996 $source = znote_update_root() . '/' . $path;
997 $exists = is_file($source);
998 $progress['backup_journal']['files'][$path] = $exists;
999 if (!$exists) {
1000 continue;
1001 }
1002 $target = $progress['backup_dir'] . '/files/' . $path;
1003 if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0750, true) && !is_dir(dirname($target))) {
1004 znote_update_install_cleanup();
1005 return array('ok' => false, 'error' => 'A backup directory cannot be created: ' . $path . '. No files were changed.');
1006 }
1007 if (!copy($source, $target)) {
1008 znote_update_install_cleanup();
1009 return array('ok' => false, 'error' => 'A managed file cannot be backed up: ' . $path . '. No files were changed.');
1010 }
1011 }
1012
1013 $doneCount = min($progress['total'], $progress['cursor'] + $batchSize);
1014 $message = 'Backing up files (' . $doneCount . '/' . $progress['total'] . ')...';
1015 $progress['cursor'] = $doneCount;
1016 if ($progress['cursor'] >= $progress['total']) {
1017 file_put_contents($progress['backup_dir'] . '/backup.json', json_encode($progress['backup_journal'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
1018 $progress['phase'] = 'migrate';
1019 $progress['cursor'] = 0;
1020 $message = 'Backup complete (' . $progress['total'] . ' files). Applying database migrations...';
1021 }
1022 znote_update_progress_save($progress);
1023 return array('ok' => true, 'phase' => $progress['phase'], 'cursor' => $progress['cursor'], 'total' => $progress['total'], 'message' => $message);
1024}
1025
1026function znote_update_install_step_migrate(array $progress, array $manifest): array
1027{
1028 $result = znote_update_apply_migrations($manifest, $progress['stage']);
1029 if (!$result['ok']) {
1030 znote_update_install_cleanup();
1031 return array('ok' => false, 'error' => $result['error'] . ' No files were changed.');
1032 }
1033 $progress['phase'] = 'copy';
1034 $progress['cursor'] = 0;
1035 znote_update_progress_save($progress);
1036 return array('ok' => true, 'phase' => 'copy', 'cursor' => 0, 'total' => $progress['total'], 'message' => 'Database migrations applied.');
1037}
1038
1039function znote_update_install_step_copy(array $progress, array $manifest, int $batchSize): array
1040{
1041 $batch = array_slice($progress['paths'], $progress['cursor'], $batchSize);
1042 $files = array();
1043 foreach ($batch as $path) {
1044 $files[$path] = $manifest['files'][$path];
1045 }
1046
1047 $backup = array('ok' => true, 'id' => $progress['backup_id'], 'directory' => $progress['backup_dir'], 'journal' => $progress['backup_journal']);
1048 $copied = znote_update_copy_files($files, $progress['stage']);
1049 if (!$copied['ok']) {
1050 $restored = znote_update_restore_backup($backup);
1051 znote_update_install_cleanup();
1052 return array('ok' => false, 'error' => $copied['error'] . ($restored['ok'] ? ' The file backup was restored.' : ' Automatic restoration failed: ' . $restored['error']));
1053 }
1054
1055 $progress['cursor'] = min($progress['total'], $progress['cursor'] + $batchSize);
1056 if ($progress['cursor'] < $progress['total']) {
1057 znote_update_progress_save($progress);
1058 return array('ok' => true, 'phase' => 'copy', 'cursor' => $progress['cursor'], 'total' => $progress['total'], 'message' => 'Installing files (' . $progress['cursor'] . '/' . $progress['total'] . ')...');
1059 }
1060
1061 file_put_contents(znote_update_storage() . '/installed.json', json_encode(array(
1062 'version' => (string)$manifest['version'],
1063 'installed_at' => gmdate(DATE_ATOM),
1064 'backup' => (string)$progress['backup_id'],
1065 'files' => $manifest['files'],
1066 ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
1067 if (function_exists('znote_cache_flush')) {
1068 znote_cache_flush();
1069 }
1070
1071 $result = array('ok' => true, 'phase' => 'done', 'cursor' => $progress['total'], 'total' => $progress['total'], 'version' => (string)$manifest['version'], 'backup' => (string)$progress['backup_id'], 'message' => 'ZnoteX was updated to version ' . $manifest['version'] . '.');
1072 znote_update_install_cleanup();
1073 return $result;
1074}
1075
1076function znote_update_install_step(int $batchSize = 40): array
1077{
1078 $progress = znote_update_progress_load();
1079 if ($progress === null) {
1080 return array('ok' => false, 'error' => 'No update is in progress.');
1081 }
1082 $manifest = $progress['manifest'];
1083
1084 switch ($progress['phase']) {
1085 case 'backup': return znote_update_install_step_backup($progress, $batchSize);
1086 case 'migrate': return znote_update_install_step_migrate($progress, $manifest);
1087 case 'copy': return znote_update_install_step_copy($progress, $manifest, $batchSize);
1088 }
1089
1090 znote_update_install_cleanup();
1091 return array('ok' => false, 'error' => 'Unknown install phase.');
1092}
1093
1094function znote_update_rollback_latest(): array
1095{
1096 $backups = znote_update_backups();
1097 if ($backups === array()) {
1098 return array('ok' => false, 'error' => 'No update backup is available.');
1099 }
1100 $lock = znote_update_storage() . '/maintenance.lock';
1101 file_put_contents($lock, json_encode(array('rollback' => true, 'started_at' => gmdate(DATE_ATOM))));
1102 try {
1103 $result = znote_update_restore_backup($backups[0]);
1104 if ($result['ok']) {
1105 if (is_file(znote_update_storage() . '/installed.json')) {
1106 unlink(znote_update_storage() . '/installed.json');
1107 }
1108 $result['version'] = (string)$backups[0]['journal']['version'];
1109 }
1110 return $result;
1111 } finally {
1112 if (is_file($lock)) {
1113 unlink($lock);
1114 }
1115 }
1116}
1117
Top