false, 'error' => 'Untrusted download address.'); } if (!function_exists('curl_init')) { return array('ok' => false, 'error' => 'The PHP cURL extension is required.'); } $handle = curl_init($url); $data = ''; $tooLarge = false; $untrustedRedirect = false; $options = array( CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 90, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'ZnoteX-Updater/' . znote_update_current_version(), CURLOPT_HTTPHEADER => array('Accept: application/vnd.github+json', 'X-GitHub-Api-Version: 2022-11-28'), CURLOPT_HEADERFUNCTION => static function ($curl, string $header) use (&$untrustedRedirect): int { if (preg_match('/^Location:\s*(\S+)/i', trim($header), $match) && str_contains($match[1], '://') && !znote_update_url_allowed($match[1])) { $untrustedRedirect = true; return 0; } return strlen($header); }, CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$data, &$tooLarge, $maxBytes): int { if (strlen($data) + strlen($chunk) > $maxBytes) { $tooLarge = true; return 0; } $data .= $chunk; return strlen($chunk); }, ); if (defined('CURLOPT_PROTOCOLS') && defined('CURLPROTO_HTTPS')) { $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTPS; $options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTPS; } if (function_exists('znote_cainfo')) { $cainfo = znote_cainfo(); if ($cainfo !== '') { $options[CURLOPT_CAINFO] = $cainfo; } } curl_setopt_array($handle, $options); $success = curl_exec($handle); $status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $error = curl_error($handle); curl_close($handle); if ($tooLarge) { return array('ok' => false, 'error' => 'The downloaded file exceeds the allowed size.'); } if ($untrustedRedirect) { return array('ok' => false, 'error' => 'GitHub returned an untrusted redirect address.'); } if ($success === false || $status < 200 || $status >= 300) { return array('ok' => false, 'error' => $error !== '' ? $error : 'HTTP error ' . $status . '.'); } return array('ok' => true, 'data' => $data); } function znote_update_asset(array $release, string $name): ?array { foreach (($release['assets'] ?? array()) as $asset) { if (is_array($asset) && (string)($asset['name'] ?? '') === $name) { return $asset; } } return null; } function znote_update_verify_manifest(string $json, string $signature): array { $key = znote_update_public_key(); if ($key === '' || !function_exists('openssl_verify')) { return array('ok' => false, 'error' => 'The update signature verifier is unavailable.'); } $decodedSignature = base64_decode(trim($signature), true); if ($decodedSignature === false || openssl_verify($json, $decodedSignature, $key, OPENSSL_ALGO_SHA256) !== 1) { return array('ok' => false, 'error' => 'The update.json signature is invalid.'); } $manifest = json_decode($json, true); if (!is_array($manifest) || (int)($manifest['schema'] ?? 0) !== ZNOTE_UPDATE_SCHEMA) { return array('ok' => false, 'error' => 'The update manifest is invalid or unsupported.'); } if (!znote_update_manifest_shape_valid($manifest)) { return array('ok' => false, 'error' => 'The signed manifest has missing or invalid fields.'); } if (!znote_update_manifest_files_valid($manifest['files'])) { return array('ok' => false, 'error' => 'The signed file list is invalid.'); } return array('ok' => true, 'manifest' => $manifest); } function znote_update_manifest_shape_valid(array $manifest): bool { $version = trim((string)($manifest['version'] ?? '')); $package = $manifest['package'] ?? null; $files = $manifest['files'] ?? null; return preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version) === 1 && is_array($package) && preg_match('/^[A-Za-z0-9._-]+\.zip$/', (string)($package['name'] ?? '')) === 1 && preg_match('/^[a-f0-9]{64}$/', (string)($package['sha256'] ?? '')) === 1 && is_array($files) && $files !== array(); } function znote_update_manifest_files_valid(array $files): bool { foreach ($files as $path => $hash) { if (!is_string($path) || znote_update_path($path) !== $path || !preg_match('/^[a-f0-9]{64}$/', (string)$hash)) { return false; } } return true; } function znote_update_path(string $path): string { $path = str_replace('\\', '/', trim($path)); $path = trim($path, '/'); $parts = explode('/', $path); if ($path === '' || preg_match('/[\x00-\x1F\x7F:*?"<>|]/', $path)) { return ''; } foreach ($parts as $part) { if ($part === '' || $part === '.' || $part === '..') { return ''; } } return implode('/', $parts); } function znote_update_protected(string $path): bool { $path = strtolower(znote_update_path($path)); foreach (array('config.php', 'config.local.php', 'plugins/', 'layouts/', 'engine/cache/', 'engine/img/theme/', 'engine/update/', 'install/', 'release/', '.git/', '.github/') as $protected) { if ($path === rtrim($protected, '/') || str_starts_with($path, $protected)) { return true; } } return false; } function znote_update_latest(bool $refresh = false): array { if (!znote_update_prepare_storage()) { return array('ok' => false, 'error' => 'The update storage directory cannot be created.'); } $cacheFile = znote_update_storage() . '/latest.json'; if (!$refresh) { $cached = znote_update_latest_cached($cacheFile); if ($cached !== null) { return $cached; } } $release = znote_update_fetch_release(); if (!$release['ok']) { return $release; } $verified = znote_update_fetch_manifest($release['release']); if (!$verified['ok']) { return $verified; } $result = znote_update_build_latest_result($release['release'], $verified['manifest']); if (!$result['ok']) { return $result; } file_put_contents($cacheFile, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); return $result; } function znote_update_latest_cached(string $cacheFile): ?array { if (!is_file($cacheFile) || filemtime($cacheFile) < time() - 900) { return null; } $cached = json_decode((string)file_get_contents($cacheFile), true); return is_array($cached) ? $cached : null; } function znote_update_fetch_release(): array { $response = znote_update_http(znote_update_api_url()); if (!$response['ok']) { return $response; } $release = json_decode($response['data'], true); if (!is_array($release) || !empty($release['draft']) || !empty($release['prerelease'])) { return array('ok' => false, 'error' => 'GitHub did not return a stable release.'); } return array('ok' => true, 'release' => $release); } function znote_update_fetch_manifest(array $release): array { $jsonAsset = znote_update_asset($release, 'update.json'); $signatureAsset = znote_update_asset($release, 'update.json.sig'); if ($jsonAsset === null || $signatureAsset === null) { return array('ok' => false, 'error' => 'The release is missing update.json or update.json.sig.'); } $jsonResponse = znote_update_http((string)($jsonAsset['browser_download_url'] ?? '')); $signatureResponse = znote_update_http((string)($signatureAsset['browser_download_url'] ?? ''), 65536); if (!$jsonResponse['ok']) { return $jsonResponse; } if (!$signatureResponse['ok']) { return $signatureResponse; } return znote_update_verify_manifest($jsonResponse['data'], $signatureResponse['data']); } function znote_update_build_latest_result(array $release, array $manifest): array { $tag = ltrim((string)($release['tag_name'] ?? ''), 'vV'); if ($tag !== (string)$manifest['version']) { return array('ok' => false, 'error' => 'The GitHub tag does not match the signed version.'); } $packageAsset = znote_update_asset($release, (string)$manifest['package']['name']); if ($packageAsset === null) { return array('ok' => false, 'error' => 'The signed ZIP package is missing from the release.'); } return array( 'ok' => true, 'available' => version_compare((string)$manifest['version'], znote_update_current_version(), '>'), 'current' => znote_update_current_version(), 'manifest' => $manifest, 'package_url' => (string)($packageAsset['browser_download_url'] ?? ''), 'release_url' => (string)($release['html_url'] ?? ''), ); } function znote_update_check(string $label, bool $ok, string $detail): array { return array('label' => $label, 'ok' => $ok, 'detail' => $detail); } function znote_update_remove_tree(string $path): void { $base = realpath(znote_update_storage()); $target = realpath($path); if ($base === false || $target === false || $target === $base || !str_starts_with(strtolower($target), strtolower($base . DIRECTORY_SEPARATOR))) { return; } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($target, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($iterator as $item) { if ($item->isDir() && !$item->isLink()) { rmdir($item->getPathname()); } else { unlink($item->getPathname()); } } rmdir($target); } function znote_update_download_package(array $latest): array { $manifest = $latest['manifest']; $name = (string)$manifest['package']['name']; $file = znote_update_storage() . '/downloads/' . $name; if (is_file($file) && hash_file('sha256', $file) === (string)$manifest['package']['sha256']) { return array('ok' => true, 'file' => $file); } $response = znote_update_http((string)$latest['package_url'], ZNOTE_UPDATE_MAX_BYTES); if (!$response['ok']) { return $response; } if (hash('sha256', $response['data']) !== (string)$manifest['package']['sha256']) { return array('ok' => false, 'error' => 'The ZIP checksum does not match the signed manifest.'); } if (file_put_contents($file, $response['data'], LOCK_EX) === false) { return array('ok' => false, 'error' => 'The ZIP package cannot be saved.'); } return array('ok' => true, 'file' => $file); } function znote_update_extract(string $zipFile, array $files, string $destination): array { if (!class_exists('ZipArchive')) { return array('ok' => false, 'error' => 'The PHP Zip extension is required.'); } if (is_dir($destination)) { znote_update_remove_tree($destination); } if (!mkdir($destination, 0750, true) && !is_dir($destination)) { return array('ok' => false, 'error' => 'The staging directory cannot be created.'); } $zip = new ZipArchive(); if ($zip->open($zipFile) !== true) { return array('ok' => false, 'error' => 'The ZIP package cannot be opened.'); } $seen = array(); for ($index = 0; $index < $zip->numFiles; $index++) { $raw = (string)$zip->getNameIndex($index); if (str_ends_with(str_replace('\\', '/', $raw), '/')) { continue; } $entry = znote_update_extract_entry($zip, $index, $raw, $files, $seen, $destination); if (!$entry['ok']) { $zip->close(); return $entry; } $seen[$entry['path']] = true; } $zip->close(); if (count($seen) !== count($files)) { return array('ok' => false, 'error' => 'The ZIP does not contain every signed file.'); } return array('ok' => true, 'directory' => $destination); } function znote_update_extract_entry(ZipArchive $zip, int $index, string $raw, array $files, array $seen, string $destination): array { $path = znote_update_path($raw); if ($path === '' || znote_update_protected($path) || !array_key_exists($path, $files) || isset($seen[$path])) { return array('ok' => false, 'error' => 'The ZIP contains an unexpected or protected file: ' . $raw); } $contents = $zip->getFromIndex($index); if (!is_string($contents) || hash('sha256', $contents) !== (string)$files[$path]) { return array('ok' => false, 'error' => 'A packaged file failed checksum validation: ' . $path); } $target = $destination . '/' . $path; $directory = dirname($target); if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { return array('ok' => false, 'error' => 'A staging directory cannot be created.'); } if (file_put_contents($target, $contents, LOCK_EX) === false) { return array('ok' => false, 'error' => 'A staged file cannot be written: ' . $path); } return array('ok' => true, 'path' => $path); } function znote_update_migration_safe(string $sql): bool { $clean = preg_replace('~/\*.*?\*/~s', ' ', $sql) ?? $sql; $clean = preg_replace('/^\s*--.*$/m', ' ', $clean) ?? $clean; if (preg_match('/\b(DROP|TRUNCATE|RENAME|CHANGE|MODIFY|DELETE|UPDATE|REPLACE)\b/i', $clean)) { return false; } foreach (array_filter(array_map('trim', explode(';', $clean))) as $statement) { 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)) { return false; } } return true; } function znote_update_check_version(string $version): array { $newer = version_compare($version, znote_update_current_version(), '>'); return znote_update_check('Version', $newer, $newer ? 'Version ' . $version . ' is newer than ' . znote_update_current_version() . '.' : 'No newer stable version is available.'); } function znote_update_check_php_version(array $requires): array { $phpConstraint = (string)($requires['php'] ?? '>=8.1'); $phpOk = function_exists('znote_extension_version_matches') && znote_extension_version_matches(PHP_VERSION, $phpConstraint); return znote_update_check('PHP version', $phpOk, 'Required ' . $phpConstraint . '; running ' . PHP_VERSION . '.'); } function znote_update_check_extensions(array $requires): array { $requiredExtensions = is_array($requires['extensions'] ?? null) ? $requires['extensions'] : array('curl', 'json', 'openssl', 'zip'); $checks = array(); foreach ($requiredExtensions as $extension) { $name = strtolower(trim((string)$extension)); $loaded = $name !== '' && extension_loaded($name); $checks[] = znote_update_check('PHP extension: ' . $name, $loaded, $loaded ? 'Loaded.' : 'Missing from this PHP installation.'); } return $checks; } function znote_update_check_disk_space(array $manifest): array { $free = @disk_free_space(znote_update_root()); $needed = max(52428800, (int)($manifest['package']['size'] ?? 0) * 3); $diskOk = is_float($free) && $free >= $needed; 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.'); } function znote_update_check_migrations(array $manifest, string $stage): array { $migrations = is_array($manifest['migrations'] ?? null) ? $manifest['migrations'] : array(); if ($migrations === array()) { return znote_update_check('Database migrations', true, 'No database migration is required.'); } foreach ($migrations as $migration) { $path = is_array($migration) ? znote_update_path((string)($migration['file'] ?? '')) : ''; $type = is_array($migration) ? (string)($migration['type'] ?? '') : ''; $file = $stage . '/' . $path; if ($type !== 'expand' || $path === '' || !is_file($file) || !znote_update_migration_safe((string)file_get_contents($file))) { return znote_update_check('Database migrations', false, 'A migration is missing, destructive or not marked as expand-only.'); } } return znote_update_check('Database migrations', true, count($migrations) . ' additive migration(s) validated.'); } function znote_update_check_local_modifications(): array { $conflicts = array(); $installedFile = znote_update_storage() . '/installed.json'; if (is_file($installedFile)) { $installed = json_decode((string)file_get_contents($installedFile), true); foreach (($installed['files'] ?? array()) as $path => $hash) { $target = znote_update_root() . '/' . znote_update_path((string)$path); if (is_file($target) && hash_file('sha256', $target) !== (string)$hash) { $conflicts[] = (string)$path; } } } 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))); } function znote_update_checks_pass(array $checks): bool { foreach ($checks as $check) { if (!$check['ok']) { return false; } } return true; } function znote_update_preflight(array $latest): array { if (empty($latest['ok'])) { return array('ok' => false, 'checks' => array(znote_update_check('Release metadata', false, (string)($latest['error'] ?? 'Unknown error.')))); } $manifest = $latest['manifest']; $version = (string)$manifest['version']; $requires = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : array(); $checks = array(); $checks[] = znote_update_check_version($version); $checks[] = znote_update_check('Digital signature', true, 'update.json has a valid ZnoteX RSA/SHA-256 signature.'); $checks[] = znote_update_check_php_version($requires); foreach (znote_update_check_extensions($requires) as $check) { $checks[] = $check; } $storageOk = znote_update_prepare_storage() && is_writable(znote_update_storage()); $rootOk = is_writable(znote_update_root()); $checks[] = znote_update_check('Update storage', $storageOk, $storageOk ? 'Writable.' : 'engine/update is not writable.'); $checks[] = znote_update_check('Website files', $rootOk, $rootOk ? 'The website root is writable.' : 'The website root is not writable by PHP.'); $checks[] = znote_update_check_disk_space($manifest); $download = $storageOk ? znote_update_download_package($latest) : array('ok' => false, 'error' => 'Storage is unavailable.'); $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.')); $stage = znote_update_storage() . '/staging/' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version); $extracted = !empty($download['ok']) ? znote_update_extract((string)$download['file'], $manifest['files'], $stage) : array('ok' => false, 'error' => 'The ZIP was not validated.'); $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.')); $checks[] = znote_update_check_migrations($manifest, $stage); $checks[] = znote_update_check_local_modifications(); return array('ok' => znote_update_checks_pass($checks), 'checks' => $checks, 'stage' => $stage, 'manifest' => $manifest); } function znote_update_check_state_file(): string { return znote_update_storage() . '/check_progress.json'; } function znote_update_check_state_load(): ?array { $file = znote_update_check_state_file(); if (!is_file($file)) { return null; } $data = json_decode((string)file_get_contents($file), true); return is_array($data) ? $data : null; } function znote_update_check_state_save(array $state): void { file_put_contents(znote_update_check_state_file(), json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } function znote_update_check_state_clear(): void { $file = znote_update_check_state_file(); if (is_file($file)) { unlink($file); } } function znote_update_check_finish(array $checks, array $manifest): array { znote_update_check_state_clear(); return array('ok' => znote_update_checks_pass($checks), 'phase' => 'done', 'checks' => $checks, 'manifest' => $manifest); } function znote_update_check_start(array $latest): array { if (empty($latest['ok'])) { return znote_update_check_finish(array(znote_update_check('Release metadata', false, (string)($latest['error'] ?? 'Unknown error.'))), array()); } $manifest = $latest['manifest']; $version = (string)$manifest['version']; $requires = is_array($manifest['requirements'] ?? null) ? $manifest['requirements'] : array(); $checks = array(); $checks[] = znote_update_check_version($version); $checks[] = znote_update_check('Digital signature', true, 'update.json has a valid ZnoteX RSA/SHA-256 signature.'); $checks[] = znote_update_check_php_version($requires); foreach (znote_update_check_extensions($requires) as $check) { $checks[] = $check; } $storageOk = znote_update_prepare_storage() && is_writable(znote_update_storage()); $rootOk = is_writable(znote_update_root()); $checks[] = znote_update_check('Update storage', $storageOk, $storageOk ? 'Writable.' : 'engine/update is not writable.'); $checks[] = znote_update_check('Website files', $rootOk, $rootOk ? 'The website root is writable.' : 'The website root is not writable by PHP.'); $checks[] = znote_update_check_disk_space($manifest); $download = $storageOk ? znote_update_download_package($latest) : array('ok' => false, 'error' => 'Storage is unavailable.'); $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.')); $stage = znote_update_storage() . '/staging/' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version); if (empty($download['ok']) || !class_exists('ZipArchive')) { $checks[] = znote_update_check('Package contents', false, !empty($download['ok']) ? 'The PHP Zip extension is required.' : 'The ZIP was not validated.'); return znote_update_check_finish($checks, $manifest); } if (is_dir($stage)) { znote_update_remove_tree($stage); } if (!mkdir($stage, 0750, true) && !is_dir($stage)) { $checks[] = znote_update_check('Package contents', false, 'The staging directory cannot be created.'); return znote_update_check_finish($checks, $manifest); } $zip = new ZipArchive(); if ($zip->open($download['file']) !== true) { $checks[] = znote_update_check('Package contents', false, 'The ZIP package cannot be opened.'); return znote_update_check_finish($checks, $manifest); } $total = $zip->numFiles; $zip->close(); znote_update_check_state_save(array( 'phase' => 'extract', 'cursor' => 0, 'seen' => array(), 'zip_file' => $download['file'], 'stage' => $stage, 'manifest' => $manifest, 'checks' => $checks, )); return array('ok' => true, 'phase' => 'extract', 'cursor' => 0, 'total' => $total, 'message' => 'Extracting package...'); } function znote_update_check_step_extract(array $state, int $batchSize): array { $zip = new ZipArchive(); if ($zip->open($state['zip_file']) !== true) { $state['checks'][] = znote_update_check('Package contents', false, 'The ZIP package cannot be opened.'); return znote_update_check_finish($state['checks'], $state['manifest']); } $total = $zip->numFiles; $end = min($total, $state['cursor'] + $batchSize); for ($index = $state['cursor']; $index < $end; $index++) { $raw = (string)$zip->getNameIndex($index); if (str_ends_with(str_replace('\\', '/', $raw), '/')) { continue; } $entry = znote_update_extract_entry($zip, $index, $raw, $state['manifest']['files'], $state['seen'], $state['stage']); if (!$entry['ok']) { $zip->close(); $state['checks'][] = znote_update_check('Package contents', false, $entry['error']); return znote_update_check_finish($state['checks'], $state['manifest']); } $state['seen'][$entry['path']] = true; } $zip->close(); $state['cursor'] = $end; if ($state['cursor'] < $total) { znote_update_check_state_save($state); return array('ok' => true, 'phase' => 'extract', 'cursor' => $state['cursor'], 'total' => $total, 'message' => 'Extracting package (' . $state['cursor'] . '/' . $total . ')...'); } $validated = count($state['seen']) === count($state['manifest']['files']); $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.'); $state['checks'][] = znote_update_check_migrations($state['manifest'], $state['stage']); $tracked = array(); $installedFile = znote_update_storage() . '/installed.json'; if (is_file($installedFile)) { $installed = json_decode((string)file_get_contents($installedFile), true); $tracked = is_array($installed['files'] ?? null) ? $installed['files'] : array(); } if ($tracked === array()) { $state['checks'][] = znote_update_check('Local modifications', true, 'No locally modified managed file will be overwritten.'); return znote_update_check_finish($state['checks'], $state['manifest']); } $state['phase'] = 'localmods'; $state['cursor'] = 0; $state['tracked'] = $tracked; $state['tracked_paths'] = array_keys($tracked); $state['conflicts'] = array(); znote_update_check_state_save($state); return array('ok' => true, 'phase' => 'localmods', 'cursor' => 0, 'total' => count($state['tracked_paths']), 'message' => 'Package validated. Checking for local modifications...'); } function znote_update_check_step_localmods(array $state, int $batchSize): array { $total = count($state['tracked_paths']); foreach (array_slice($state['tracked_paths'], $state['cursor'], $batchSize) as $path) { $target = znote_update_root() . '/' . znote_update_path((string)$path); if (is_file($target) && hash_file('sha256', $target) !== (string)$state['tracked'][$path]) { $state['conflicts'][] = (string)$path; } } $state['cursor'] = min($total, $state['cursor'] + $batchSize); if ($state['cursor'] < $total) { znote_update_check_state_save($state); return array('ok' => true, 'phase' => 'localmods', 'cursor' => $state['cursor'], 'total' => $total, 'message' => 'Checking local files (' . $state['cursor'] . '/' . $total . ')...'); } $conflicts = $state['conflicts']; $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))); return znote_update_check_finish($state['checks'], $state['manifest']); } function znote_update_check_step(int $batchSize = 60): array { $state = znote_update_check_state_load(); if ($state === null) { return array('ok' => false, 'error' => 'No check is in progress.'); } switch ($state['phase']) { case 'extract': return znote_update_check_step_extract($state, $batchSize); case 'localmods': return znote_update_check_step_localmods($state, $batchSize); } znote_update_check_state_clear(); return array('ok' => false, 'error' => 'Unknown check phase.'); } function znote_update_backup(array $files, string $version): array { $id = gmdate('Ymd-His') . '-from-' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version); $directory = znote_update_storage() . '/backups/' . $id; if (!mkdir($directory, 0750, true) && !is_dir($directory)) { return array('ok' => false, 'error' => 'The backup directory cannot be created.'); } $journal = array('id' => $id, 'version' => $version, 'created_at' => gmdate(DATE_ATOM), 'files' => array()); foreach (array_keys($files) as $path) { $source = znote_update_root() . '/' . $path; $journal['files'][$path] = is_file($source); if (!is_file($source)) { continue; } $target = $directory . '/files/' . $path; if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0750, true) && !is_dir(dirname($target))) { return array('ok' => false, 'error' => 'A backup directory cannot be created.'); } if (!copy($source, $target)) { return array('ok' => false, 'error' => 'A managed file cannot be backed up: ' . $path); } } file_put_contents($directory . '/backup.json', json_encode($journal, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return array('ok' => true, 'id' => $id, 'directory' => $directory, 'journal' => $journal); } function znote_update_restore_backup(array $backup): array { $directory = (string)($backup['directory'] ?? ''); $journal = $backup['journal'] ?? null; if (!is_array($journal) || !is_dir($directory)) { return array('ok' => false, 'error' => 'The backup is invalid.'); } foreach (($journal['files'] ?? array()) as $path => $existed) { $path = znote_update_path((string)$path); if ($path === '' || znote_update_protected($path)) { return array('ok' => false, 'error' => 'The backup contains an invalid path.'); } $target = znote_update_root() . '/' . $path; if ($existed) { $source = $directory . '/files/' . $path; if (!is_file($source)) { return array('ok' => false, 'error' => 'A backup file is missing: ' . $path); } if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0755, true) && !is_dir(dirname($target))) { return array('ok' => false, 'error' => 'A destination directory cannot be restored.'); } if (!copy($source, $target)) { return array('ok' => false, 'error' => 'A file cannot be restored: ' . $path); } } elseif (is_file($target)) { unlink($target); } } return array('ok' => true); } function znote_update_apply_migrations(array $manifest, string $stage): array { $migrations = is_array($manifest['migrations'] ?? null) ? $manifest['migrations'] : array(); if ($migrations === array()) { return array('ok' => true); } 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'); foreach ($migrations as $migration) { $path = znote_update_path((string)($migration['file'] ?? '')); $file = $stage . '/' . $path; $sql = is_file($file) ? (string)file_get_contents($file) : ''; $checksum = hash('sha256', $sql); $found = db()->fetchOne('SELECT checksum FROM znote_migrations WHERE migration = ?', array($path)); if (is_array($found)) { if ((string)($found['checksum'] ?? '') !== $checksum) { return array('ok' => false, 'error' => 'An applied migration has changed: ' . $path); } continue; } $started = microtime(true); foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) { if (!db()->execute($statement)) { return array('ok' => false, 'error' => 'Database migration failed: ' . $path); } } $milliseconds = (int)round((microtime(true) - $started) * 1000); db()->execute('INSERT INTO znote_migrations (migration, checksum, executed_at, execution_time_ms) VALUES (?, ?, ?, ?)', array($path, $checksum, time(), $milliseconds)); } return array('ok' => true); } function znote_update_copy_files(array $files, string $stage): array { foreach ($files as $path => $hash) { $source = $stage . '/' . $path; $target = znote_update_root() . '/' . $path; $directory = dirname($target); if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) { return array('ok' => false, 'error' => 'A destination directory cannot be created: ' . $path); } $temp = $target . '.znote-update-' . bin2hex(random_bytes(6)); if (!copy($source, $temp) || hash_file('sha256', $temp) !== $hash) { if (is_file($temp)) { unlink($temp); } return array('ok' => false, 'error' => 'A file cannot be prepared: ' . $path); } if (!rename($temp, $target)) { if (!copy($temp, $target)) { unlink($temp); return array('ok' => false, 'error' => 'A file cannot be installed: ' . $path); } unlink($temp); } if (hash_file('sha256', $target) !== $hash) { return array('ok' => false, 'error' => 'An installed file failed checksum validation: ' . $path); } } return array('ok' => true); } function znote_update_install(array $latest): array { @set_time_limit(0); @ini_set('max_execution_time', '0'); $preflight = znote_update_preflight($latest); if (!$preflight['ok']) { return array('ok' => false, 'error' => 'The pre-installation check is not fully green.', 'checks' => $preflight['checks']); } $manifest = $preflight['manifest']; $backup = znote_update_backup($manifest['files'], znote_update_current_version()); if (!$backup['ok']) { return $backup; } $migrations = znote_update_apply_migrations($manifest, $preflight['stage']); if (!$migrations['ok']) { return $migrations; } $lock = znote_update_storage() . '/maintenance.lock'; file_put_contents($lock, json_encode(array('version' => $manifest['version'], 'started_at' => gmdate(DATE_ATOM)))); try { $copied = znote_update_copy_files($manifest['files'], $preflight['stage']); if (!$copied['ok']) { $restored = znote_update_restore_backup($backup); return array('ok' => false, 'error' => $copied['error'] . ($restored['ok'] ? ' The file backup was restored.' : ' Automatic restoration failed: ' . $restored['error'])); } $installed = array( 'version' => (string)$manifest['version'], 'installed_at' => gmdate(DATE_ATOM), 'backup' => (string)$backup['id'], 'files' => $manifest['files'], ); file_put_contents(znote_update_storage() . '/installed.json', json_encode($installed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); if (function_exists('znote_cache_flush')) { znote_cache_flush(); } return array('ok' => true, 'version' => (string)$manifest['version'], 'backup' => (string)$backup['id']); } finally { if (is_file($lock)) { unlink($lock); } } } function znote_update_backups(): array { $result = array(); foreach (glob(znote_update_storage() . '/backups/*/backup.json') ?: array() as $file) { $journal = json_decode((string)file_get_contents($file), true); if (is_array($journal)) { $result[] = array('directory' => dirname($file), 'journal' => $journal); } } usort($result, static fn(array $a, array $b): int => strcmp((string)$b['journal']['created_at'], (string)$a['journal']['created_at'])); return $result; } function znote_update_progress_file(): string { return znote_update_storage() . '/progress.json'; } function znote_update_progress_load(): ?array { $file = znote_update_progress_file(); if (!is_file($file)) { return null; } $data = json_decode((string)file_get_contents($file), true); return is_array($data) ? $data : null; } function znote_update_progress_save(array $progress): void { file_put_contents(znote_update_progress_file(), json_encode($progress, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } function znote_update_install_cleanup(): void { $file = znote_update_progress_file(); if (is_file($file)) { unlink($file); } $lock = znote_update_storage() . '/maintenance.lock'; if (is_file($lock)) { unlink($lock); } } function znote_update_install_start(array $latest): array { $preflight = znote_update_preflight($latest); if (!$preflight['ok']) { return array('ok' => false, 'error' => 'The pre-installation check is not fully green.', 'checks' => $preflight['checks']); } $manifest = $preflight['manifest']; $paths = array_keys($manifest['files']); $id = gmdate('Ymd-His') . '-from-' . preg_replace('/[^0-9A-Za-z._-]/', '-', znote_update_current_version()); $backupDir = znote_update_storage() . '/backups/' . $id; if (!mkdir($backupDir, 0750, true) && !is_dir($backupDir)) { return array('ok' => false, 'error' => 'The backup directory cannot be created.'); } znote_update_progress_save(array( 'phase' => 'backup', 'cursor' => 0, 'paths' => $paths, 'total' => count($paths), 'stage' => $preflight['stage'], 'manifest' => $manifest, 'backup_id' => $id, 'backup_dir' => $backupDir, 'backup_journal' => array('id' => $id, 'version' => znote_update_current_version(), 'created_at' => gmdate(DATE_ATOM), 'files' => array()), )); $lock = znote_update_storage() . '/maintenance.lock'; file_put_contents($lock, json_encode(array('version' => $manifest['version'], 'started_at' => gmdate(DATE_ATOM)))); return array('ok' => true, 'phase' => 'backup', 'cursor' => 0, 'total' => count($paths), 'message' => 'Starting backup...'); } function znote_update_install_step_backup(array $progress, int $batchSize): array { foreach (array_slice($progress['paths'], $progress['cursor'], $batchSize) as $path) { $source = znote_update_root() . '/' . $path; $exists = is_file($source); $progress['backup_journal']['files'][$path] = $exists; if (!$exists) { continue; } $target = $progress['backup_dir'] . '/files/' . $path; if (!is_dir(dirname($target)) && !mkdir(dirname($target), 0750, true) && !is_dir(dirname($target))) { znote_update_install_cleanup(); return array('ok' => false, 'error' => 'A backup directory cannot be created: ' . $path . '. No files were changed.'); } if (!copy($source, $target)) { znote_update_install_cleanup(); return array('ok' => false, 'error' => 'A managed file cannot be backed up: ' . $path . '. No files were changed.'); } } $doneCount = min($progress['total'], $progress['cursor'] + $batchSize); $message = 'Backing up files (' . $doneCount . '/' . $progress['total'] . ')...'; $progress['cursor'] = $doneCount; if ($progress['cursor'] >= $progress['total']) { file_put_contents($progress['backup_dir'] . '/backup.json', json_encode($progress['backup_journal'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); $progress['phase'] = 'migrate'; $progress['cursor'] = 0; $message = 'Backup complete (' . $progress['total'] . ' files). Applying database migrations...'; } znote_update_progress_save($progress); return array('ok' => true, 'phase' => $progress['phase'], 'cursor' => $progress['cursor'], 'total' => $progress['total'], 'message' => $message); } function znote_update_install_step_migrate(array $progress, array $manifest): array { $result = znote_update_apply_migrations($manifest, $progress['stage']); if (!$result['ok']) { znote_update_install_cleanup(); return array('ok' => false, 'error' => $result['error'] . ' No files were changed.'); } $progress['phase'] = 'copy'; $progress['cursor'] = 0; znote_update_progress_save($progress); return array('ok' => true, 'phase' => 'copy', 'cursor' => 0, 'total' => $progress['total'], 'message' => 'Database migrations applied.'); } function znote_update_install_step_copy(array $progress, array $manifest, int $batchSize): array { $batch = array_slice($progress['paths'], $progress['cursor'], $batchSize); $files = array(); foreach ($batch as $path) { $files[$path] = $manifest['files'][$path]; } $backup = array('ok' => true, 'id' => $progress['backup_id'], 'directory' => $progress['backup_dir'], 'journal' => $progress['backup_journal']); $copied = znote_update_copy_files($files, $progress['stage']); if (!$copied['ok']) { $restored = znote_update_restore_backup($backup); znote_update_install_cleanup(); return array('ok' => false, 'error' => $copied['error'] . ($restored['ok'] ? ' The file backup was restored.' : ' Automatic restoration failed: ' . $restored['error'])); } $progress['cursor'] = min($progress['total'], $progress['cursor'] + $batchSize); if ($progress['cursor'] < $progress['total']) { znote_update_progress_save($progress); return array('ok' => true, 'phase' => 'copy', 'cursor' => $progress['cursor'], 'total' => $progress['total'], 'message' => 'Installing files (' . $progress['cursor'] . '/' . $progress['total'] . ')...'); } file_put_contents(znote_update_storage() . '/installed.json', json_encode(array( 'version' => (string)$manifest['version'], 'installed_at' => gmdate(DATE_ATOM), 'backup' => (string)$progress['backup_id'], 'files' => $manifest['files'], ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); if (function_exists('znote_cache_flush')) { znote_cache_flush(); } $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'] . '.'); znote_update_install_cleanup(); return $result; } function znote_update_install_step(int $batchSize = 40): array { $progress = znote_update_progress_load(); if ($progress === null) { return array('ok' => false, 'error' => 'No update is in progress.'); } $manifest = $progress['manifest']; switch ($progress['phase']) { case 'backup': return znote_update_install_step_backup($progress, $batchSize); case 'migrate': return znote_update_install_step_migrate($progress, $manifest); case 'copy': return znote_update_install_step_copy($progress, $manifest, $batchSize); } znote_update_install_cleanup(); return array('ok' => false, 'error' => 'Unknown install phase.'); } function znote_update_rollback_latest(): array { $backups = znote_update_backups(); if ($backups === array()) { return array('ok' => false, 'error' => 'No update backup is available.'); } $lock = znote_update_storage() . '/maintenance.lock'; file_put_contents($lock, json_encode(array('rollback' => true, 'started_at' => gmdate(DATE_ATOM)))); try { $result = znote_update_restore_backup($backups[0]); if ($result['ok']) { if (is_file(znote_update_storage() . '/installed.json')) { unlink(znote_update_storage() . '/installed.json'); } $result['version'] = (string)$backups[0]['journal']['version']; } return $result; } finally { if (is_file($lock)) { unlink($lock); } } }