v3.0.0.2
FlatlyPage
- <?php
-
- declare(strict_types=1);
-
- if (!defined('BASE_DIR')) {
- exit;
- }
-
- final class Updater
- {
- public const MANIFEST_URL = 'https://weborbiton.eu/updater/flatlypage/';
-
- private const MAX_RESPONSE_BYTES = 8388608;
- private const MAX_FILES = 300;
- private const MAX_FILE_BYTES = 524288;
- private const MAX_DIFF_CELLS = 400000;
- private const MIN_MOVED_LENGTH = 6;
- private const MAX_ZIP_BYTES = 67108864;
- private const MAX_HASHED_BYTES = 33554432;
- private const CONTEXT_LINES = 3;
- private const AUTO_MIN_INTERVAL = 600;
- private const TEXT_EXTENSIONS = ['php', 'js', 'css', 'sql', 'txt', 'md', 'json', 'html', 'htm', 'svg', 'xml', 'htaccess', 'webmanifest'];
- private const EXCLUDED_PREFIXES = ['media/', 'temp-data/', 'contacts/', 'extensions/', 'updater-files/', '.git/', 'css/theme.css'];
-
- public static function currentVersion(): string
- {
- $raw = @file_get_contents(BASE_DIR . '/version.txt');
-
- return $raw === false ? '0' : self::normalizeVersion($raw);
- }
-
- public static function normalizeVersion(string $raw): string
- {
- if (preg_match('/Version:\s*([0-9A-Za-z.+_-]+)/i', $raw, $match) === 1) {
- return $match[1];
- }
-
- $lines = preg_split('/\R/', trim($raw)) ?: [];
-
- return trim((string) ($lines[0] ?? ''));
- }
-
- public static function enabled(): bool
- {
- $settings = load_page('updater');
-
- return is_array($settings) && !empty($settings['enabled']);
- }
-
- public static function setEnabled(bool $enabled): bool
- {
- return self::saveSetting('enabled', $enabled);
- }
-
- public static function autoCleanup(): bool
- {
- $settings = load_page('updater');
-
- return is_array($settings) && !empty($settings['auto_cleanup']);
- }
-
- public static function setAutoCleanup(bool $enabled): bool
- {
- return self::saveSetting('auto_cleanup', $enabled);
- }
-
- private static function saveSetting(string $key, mixed $value): bool
- {
- return self::saveSettings([$key => $value]);
- }
-
- private static function saveSettings(array $values): bool
- {
- $settings = load_page('updater');
- $settings = is_array($settings) ? $settings : [];
-
- return save_page('updater', array_merge($settings, $values));
- }
-
- public static function autoUpdate(): bool
- {
- $settings = load_page('updater');
-
- return is_array($settings) && !empty($settings['auto_update']);
- }
-
- public static function setAutoUpdate(bool $enabled): bool
- {
- if ($enabled) {
- self::cronToken();
- }
-
- return self::saveSetting('auto_update', $enabled);
- }
-
- public static function cronToken(): string
- {
- $settings = load_page('updater');
- $token = is_array($settings) ? (string) ($settings['cron_token'] ?? '') : '';
- if (preg_match('/^[a-f0-9]{48}$/', $token) !== 1) {
- $token = self::regenerateCronToken();
- }
-
- return $token;
- }
-
- public static function regenerateCronToken(): string
- {
- $token = bin2hex(random_bytes(24));
- self::saveSetting('cron_token', $token);
-
- return $token;
- }
-
- public static function lastAutomaticRun(): ?array
- {
- $settings = load_page('updater');
- if (!is_array($settings) || empty($settings['auto_last_at'])) {
- return null;
- }
-
- return [
- 'at' => (string) $settings['auto_last_at'],
- 'ok' => !empty($settings['auto_last_ok']),
- 'message' => (string) ($settings['auto_last_message'] ?? ''),
- ];
- }
-
- public static function runAutomatic(): array
- {
- if (!self::enabled() || !self::autoUpdate()) {
- return ['ok' => true, 'message' => 'Automatic updates are turned off. You can turn them on in Updater.'];
- }
-
- $settings = load_page('updater');
- $lastStart = is_array($settings) ? (int) ($settings['auto_last_start'] ?? 0) : 0;
- if ($lastStart > time() - self::AUTO_MIN_INTERVAL) {
- return ['ok' => true, 'message' => 'Skipped, the last run was less than ' . intdiv(self::AUTO_MIN_INTERVAL, 60) . ' minutes ago.'];
- }
- self::saveSetting('auto_last_start', time());
-
- $current = self::currentVersion();
- $check = self::check($current);
- if (!$check['ok']) {
- return self::recordAutomatic(false, "Couldn't check for updates: " . $check['error']);
- }
- if ($check['status'] !== 'update') {
- return self::recordAutomatic(true, 'Nothing to update, you already have version ' . $current . '.');
- }
-
- $outcome = self::install($current, false);
- if (!$outcome['ok']) {
- return self::recordAutomatic(false, "Couldn't update to " . $check['remote_version'] . ': ' . $outcome['error']);
- }
-
- $problem = self::healthCheck();
- if ($problem !== null) {
- $restore = self::restore($outcome['backup']);
-
- return self::recordAutomatic(false, 'Updated to ' . $outcome['version'] . ', but then ' . $problem . '. '
- . ($restore['ok'] ? 'Your previous files were put back from backup ' . $outcome['backup'] . '.' : "Couldn't put back backup " . $outcome['backup'] . ': ' . $restore['error']));
- }
-
- if (self::autoCleanup()) {
- self::cleanupBackups();
- }
-
- return self::recordAutomatic(true, 'Updated from ' . $current . ' to ' . $outcome['version'] . ' (' . $outcome['installed'] . ' files, backup ' . $outcome['backup'] . ').');
- }
-
- private static function recordAutomatic(bool $ok, string $message): array
- {
- self::saveSettings(['auto_last_at' => date('c'), 'auto_last_ok' => $ok, 'auto_last_message' => $message]);
- self::log('[auto] ' . $message);
-
- return ['ok' => $ok, 'message' => $message];
- }
-
- private static function healthCheck(): ?string
- {
- $base = flatly_configured_site_url() ?? (PHP_SAPI === 'cli' ? null : SITE_URL);
- if ($base === null || !function_exists('curl_init')) {
- return null;
- }
-
- foreach (['/' => 'the homepage', '/admin/' => 'the admin login page'] as $path => $label) {
- $curl = curl_init(rtrim($base, '/') . $path);
- curl_setopt_array($curl, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_CONNECTTIMEOUT => 10,
- CURLOPT_TIMEOUT => 30,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_MAXREDIRS => 3,
- CURLOPT_USERAGENT => 'FlatlyPage-AutoUpdate',
- ]);
- $body = curl_exec($curl);
- $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
- curl_close($curl);
-
- if ($body === false || $status === 0) {
- continue;
- }
- if ($status >= 500) {
- return $label . ' stopped loading (error ' . $status . ')';
- }
- if (preg_match('/<b>(Fatal|Parse) error<\/b>:|^(PHP )?(Fatal|Parse) error:/mi', (string) $body) === 1) {
- return $label . ' showed a PHP error';
- }
- }
-
- return null;
- }
-
- public static function cleanupBackups(int $days = 30): int
- {
- $limit = time() - $days * 86400;
- $removed = 0;
-
- foreach (glob(self::storagePath('backups') . '/*', GLOB_ONLYDIR) ?: [] as $directory) {
- $id = basename($directory);
- if (self::backupDirectory($id) === null) {
- continue;
- }
-
- $meta = self::readMeta($directory);
- $created = is_array($meta) && !empty($meta['created_at']) ? strtotime((string) $meta['created_at']) : false;
- if ($created === false) {
- $parsed = DateTime::createFromFormat('Ymd-His', substr($id, 0, 15));
- $created = $parsed !== false ? $parsed->getTimestamp() : false;
- }
-
- if ($created !== false && $created < $limit && self::deleteBackup($id)) {
- $removed++;
- }
- }
-
- if ($removed > 0) {
- self::log('Removed ' . $removed . ' backup(s) older than ' . $days . ' days.');
- }
-
- return $removed;
- }
-
- public static function storagePath(string $sub = ''): string
- {
- return BASE_DIR . '/updater-files' . ($sub !== '' ? '/' . $sub : '');
- }
-
- public static function check(string $currentVersion): array
- {
- [$body, $error] = self::request(self::MANIFEST_URL . '?version=' . rawurlencode($currentVersion));
- if ($body === null) {
- return ['ok' => false, 'error' => $error ?? 'No response from the update server.'];
- }
-
- $manifest = json_decode($body, true);
- if (!is_array($manifest) || !isset($manifest['version']) || !is_string($manifest['version'])
- || self::normalizeVersion($manifest['version']) === '') {
- $message = is_array($manifest) && isset($manifest['error']) && is_string($manifest['error']) ? $manifest['error'] : 'The update server returned an invalid response.';
-
- return ['ok' => false, 'error' => $message];
- }
-
- $remoteVersion = self::normalizeVersion($manifest['version']);
- $comparison = version_compare($remoteVersion, $currentVersion);
-
- $changelog = $manifest['changelog'] ?? [];
- if (is_string($changelog)) {
- $changelog = preg_split('/\R/', $changelog) ?: [];
- }
- $changelog = array_values(array_filter(array_map(
- static fn($item) => is_scalar($item) ? trim((string) $item) : '',
- is_array($changelog) ? $changelog : []
- ), static fn(string $item) => $item !== ''));
-
- return [
- 'ok' => true,
- 'remote_version' => $remoteVersion,
- 'released_at' => isset($manifest['released_at']) && is_scalar($manifest['released_at']) ? (string) $manifest['released_at'] : '',
- 'status' => $comparison > 0 ? 'update' : ($comparison === 0 ? 'current' : 'ahead'),
- 'changelog' => $changelog,
- 'sha256' => isset($manifest['sha256']) && is_string($manifest['sha256']) ? strtolower(trim($manifest['sha256'])) : '',
- 'files' => [],
- 'skipped' => 0,
- 'compared' => false,
- ];
- }
-
- public static function compare(string $currentVersion): array
- {
- $result = self::check($currentVersion);
- if (!$result['ok']) {
- return $result;
- }
-
- $problem = self::releaseProblem($result);
- if ($problem !== null) {
- return ['ok' => false, 'error' => $problem];
- }
-
- $prepared = self::prepareRelease($result);
- if (!$prepared['ok']) {
- return $prepared;
- }
- $temporary = $prepared['path'];
-
- try {
- $zip = new ZipArchive();
- if ($zip->open($temporary) !== true) {
- return ['ok' => false, 'error' => 'The downloaded update is damaged. Please try again later.'];
- }
-
- $entries = self::readRelease($zip);
- $zip->close();
-
- if ($entries === null) {
- return ['ok' => false, 'error' => 'The downloaded update is incomplete. Please try again later.'];
- }
- } finally {
- @unlink($temporary);
- }
-
- $skipped = 0;
- foreach ($entries as $entry) {
- if (count($result['files']) >= self::MAX_FILES) {
- break;
- }
- $analysed = self::analyseFile($entry);
- if ($analysed === null) {
- $skipped++;
- continue;
- }
- $result['files'][] = $analysed;
- }
- $result['skipped'] = $skipped;
- $result['compared'] = true;
-
- return $result;
- }
-
- public static function install(string $currentVersion, bool $overwriteHtaccess = false): array
- {
- ignore_user_abort(true);
-
- $result = self::check($currentVersion);
- if (!$result['ok']) {
- return $result;
- }
- if ($result['status'] === 'ahead') {
- return ['ok' => false, 'error' => 'Your version is already newer than the latest release.'];
- }
-
- $problem = self::releaseProblem($result);
- if ($problem !== null) {
- return ['ok' => false, 'error' => $problem];
- }
-
- $storageError = self::ensureStorage();
- if ($storageError !== null) {
- return ['ok' => false, 'error' => $storageError];
- }
-
- $lock = fopen(self::storagePath('install.lock'), 'c');
- if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
- return ['ok' => false, 'error' => 'Another update is already running. Please wait a moment.'];
- }
-
- $temporary = null;
- $zip = null;
-
- try {
- $prepared = self::prepareRelease($result);
- if (!$prepared['ok']) {
- throw new RuntimeException($prepared['error']);
- }
- $temporary = $prepared['path'];
-
- $zip = new ZipArchive();
- if ($zip->open($temporary) !== true) {
- throw new RuntimeException('The downloaded update is damaged. Please try again later.');
- }
-
- $prefix = self::releasePrefix($zip);
- if ($prefix === null) {
- throw new RuntimeException('The downloaded update is incomplete. Please try again later.');
- }
- if (self::normalizeVersion((string) $zip->getFromName($prefix . 'version.txt')) !== $result['remote_version']) {
- throw new RuntimeException("The downloaded update doesn't match the expected version, so it wasn't installed.");
- }
-
- $plan = self::planInstall($zip, $prefix, $overwriteHtaccess);
- if ($plan === []) {
- throw new RuntimeException($overwriteHtaccess
- ? 'Your files already match the release.'
- : 'Your files already match the release, except possibly .htaccess, which is only replaced when you choose to overwrite it.');
- }
-
- $backupId = self::createBackup($plan, $currentVersion, $result['remote_version']);
-
- try {
- foreach ($plan as $item) {
- self::writeFile($zip, $item);
- }
- } catch (Throwable $failure) {
- self::rollback($plan, $backupId);
- self::log('Installation of ' . $result['remote_version'] . ' failed and was rolled back: ' . $failure->getMessage());
- throw new RuntimeException("The update didn't finish, so everything was put back the way it was: " . $failure->getMessage());
- }
-
- self::markBackup($backupId, ['completed_at' => date('c')]);
- self::log('Installed ' . $result['remote_version'] . ' over ' . $currentVersion . ' (' . count($plan) . ' files, backup ' . $backupId . ').');
-
- return ['ok' => true, 'version' => $result['remote_version'], 'installed' => count($plan), 'backup' => $backupId];
- } catch (Throwable $exception) {
- return ['ok' => false, 'error' => $exception->getMessage()];
- } finally {
- if ($zip instanceof ZipArchive) {
- @$zip->close();
- }
- if ($temporary !== null) {
- @unlink($temporary);
- }
- flock($lock, LOCK_UN);
- fclose($lock);
- }
- }
-
- public static function restore(string $backupId): array
- {
- ignore_user_abort(true);
-
- $directory = self::backupDirectory($backupId);
- $meta = $directory !== null ? self::readMeta($directory) : null;
- if ($meta === null) {
- return ['ok' => false, 'error' => "Couldn't find that backup."];
- }
-
- $storageError = self::ensureStorage();
- if ($storageError !== null) {
- return ['ok' => false, 'error' => $storageError];
- }
-
- $lock = fopen(self::storagePath('install.lock'), 'c');
- if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
- return ['ok' => false, 'error' => 'Another update is already running. Please wait a moment.'];
- }
-
- try {
- $items = [];
- foreach ($meta['files'] as $file) {
- $path = (string) ($file['path'] ?? '');
- $target = self::resolveLocalPath($path);
- if ($target === null || self::isExcluded($path)) {
- continue;
- }
- $status = ($file['status'] ?? '') === 'added' ? 'added' : 'modified';
- if ($status === 'modified' && !is_file($directory . '/files/' . $path)) {
- throw new RuntimeException('This backup is incomplete, ' . $path . ' is missing.');
- }
- $items[] = ['path' => $path, 'status' => $status, 'target' => $target];
- }
-
- foreach ($items as $item) {
- if ($item['status'] === 'added') {
- if (is_file($item['target'])) {
- @unlink($item['target']);
- }
- } else {
- self::copyInto($directory . '/files/' . $item['path'], $item['target']);
- }
- invalidate_php_cache($item['target']);
- }
-
- self::markBackup($backupId, ['restored_at' => date('c')]);
- self::log('Restored backup ' . $backupId . ' (' . count($items) . ' files).');
-
- return ['ok' => true, 'restored' => count($items), 'version' => self::currentVersion()];
- } catch (Throwable $exception) {
- return ['ok' => false, 'error' => $exception->getMessage()];
- } finally {
- flock($lock, LOCK_UN);
- fclose($lock);
- }
- }
-
- public static function backups(): array
- {
- $list = [];
- foreach (glob(self::storagePath('backups') . '/*', GLOB_ONLYDIR) ?: [] as $directory) {
- $id = basename($directory);
- $meta = self::backupDirectory($id) !== null ? self::readMeta($directory) : null;
- if ($meta === null) {
- continue;
- }
- $list[] = [
- 'id' => $id,
- 'from_version' => (string) ($meta['from_version'] ?? ''),
- 'to_version' => (string) ($meta['to_version'] ?? ''),
- 'created_at' => (string) ($meta['created_at'] ?? ''),
- 'files' => count($meta['files']),
- 'completed' => !empty($meta['completed_at']),
- 'restored' => !empty($meta['restored_at']),
- ];
- }
- usort($list, static fn(array $a, array $b) => strcmp($b['id'], $a['id']));
-
- return $list;
- }
-
- public static function deleteBackup(string $backupId): bool
- {
- $directory = self::backupDirectory($backupId);
- if ($directory === null) {
- return false;
- }
-
- $items = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::CHILD_FIRST
- );
- foreach ($items as $item) {
- $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
- }
-
- return @rmdir($directory);
- }
-
- private static function releaseProblem(array $manifest): ?string
- {
- if (!class_exists('ZipArchive')) {
- return 'Your server needs the PHP zip extension to install updates. Ask your hosting provider to turn it on.';
- }
-
- if (preg_match('/^[0-9a-f]{64}$/', $manifest['sha256']) !== 1) {
- return "The update server didn't send a way to verify this update, so it wasn't installed.";
- }
-
- return null;
- }
-
- private static function ensureStorage(): ?string
- {
- foreach ([self::storagePath(), self::storagePath('backups'), self::storagePath('tmp')] as $directory) {
- if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) {
- return "Couldn't create the folder for updates. Check the folder permissions on your server.";
- }
- }
-
- $guards = [
- self::storagePath('.htaccess') => "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n",
- self::storagePath('index.html') => '',
- ];
- foreach ($guards as $path => $content) {
- if (!is_file($path)) {
- @file_put_contents($path, $content);
- }
- }
-
- return null;
- }
-
- private static function prepareRelease(array $manifest): array
- {
- $storageError = self::ensureStorage();
- if ($storageError !== null) {
- return ['ok' => false, 'error' => $storageError];
- }
-
- $temporary = tempnam(self::storagePath('tmp'), 'fpu');
- if ($temporary === false) {
- return ['ok' => false, 'error' => "Couldn't create a temporary file for the update. Check the folder permissions on your server."];
- }
-
- $error = self::download(self::MANIFEST_URL . '?download=1', $temporary);
- if ($error === null && !hash_equals($manifest['sha256'], (string) hash_file('sha256', $temporary))) {
- $error = 'The downloaded release does not match its checksum.';
- }
-
- if ($error !== null) {
- @unlink($temporary);
-
- return ['ok' => false, 'error' => $error];
- }
-
- return ['ok' => true, 'path' => $temporary];
- }
-
- private static function isExcluded(string $path): bool
- {
- if (str_starts_with($path, 'data/')) {
- return preg_match('#^data/default[^/]*$#', $path) !== 1;
- }
-
- foreach (self::EXCLUDED_PREFIXES as $excluded) {
- if (str_starts_with($path, $excluded)) {
- return true;
- }
- }
-
- if (preg_match('#^(favicon|apple-touch-icon|android-chrome|mstile|safari-pinned-tab)[^/]*$#i', $path) === 1) {
- return true;
- }
-
- return $path === 'changelog.txt' || str_ends_with($path, '.fpu-new');
- }
-
- private static function hashZipEntry(ZipArchive $zip, string $name): ?string
- {
- $stream = $zip->getStream($name);
- if ($stream === false) {
- return null;
- }
-
- $hash = hash_init('sha256');
- while (!feof($stream)) {
- hash_update($hash, (string) fread($stream, 65536));
- }
- fclose($stream);
-
- return hash_final($hash);
- }
-
- private static function planInstall(ZipArchive $zip, string $prefix, bool $overwriteHtaccess): array
- {
- $plan = [];
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex($i);
- if (str_ends_with($name, '/') || !str_starts_with($name, $prefix)) {
- continue;
- }
-
- $path = substr($name, strlen($prefix));
- $target = self::resolveLocalPath($path);
- if ($target === null || self::isExcluded($path) || is_link($target) || is_dir($target)) {
- continue;
- }
- if ($path === '.htaccess' && !$overwriteHtaccess) {
- continue;
- }
-
- $stat = $zip->statIndex($i);
- if ((int) ($stat['size'] ?? 0) > self::MAX_HASHED_BYTES) {
- throw new RuntimeException('The file ' . $path . ' is too large to install.');
- }
-
- $newHash = self::hashZipEntry($zip, $name);
- if ($newHash === null) {
- throw new RuntimeException("Couldn't read " . $path . ' from the update.');
- }
-
- $exists = is_file($target);
- if ($exists && hash_equals($newHash, (string) hash_file('sha256', $target))) {
- continue;
- }
-
- $plan[] = ['path' => $path, 'zip' => $name, 'target' => $target, 'hash' => $newHash, 'status' => $exists ? 'modified' : 'added'];
- if (count($plan) > self::MAX_FILES * 4) {
- throw new RuntimeException('This update changes too many files to install safely.');
- }
- }
-
- usort($plan, static fn(array $a, array $b) => ($a['path'] === 'version.txt') <=> ($b['path'] === 'version.txt'));
-
- return $plan;
- }
-
- private static function createBackup(array $plan, string $fromVersion, string $toVersion): string
- {
- $id = date('Ymd-His') . '_' . trim((string) preg_replace('/[^A-Za-z0-9.]+/', '-', $fromVersion), '-');
- $directory = self::storagePath('backups/' . $id);
-
- if (is_dir($directory) || !@mkdir($directory, 0750, true)) {
- throw new RuntimeException("Couldn't create the backup folder.");
- }
-
- try {
- foreach ($plan as $item) {
- if ($item['status'] === 'modified') {
- self::copyInto($item['target'], $directory . '/files/' . $item['path']);
- }
- }
-
- $meta = [
- 'from_version' => $fromVersion,
- 'to_version' => $toVersion,
- 'created_at' => date('c'),
- 'files' => array_map(static fn(array $item) => ['path' => $item['path'], 'status' => $item['status']], $plan),
- ];
- if (file_put_contents($directory . '/meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
- throw new RuntimeException("Couldn't save the backup details.");
- }
- } catch (Throwable $exception) {
- self::deleteBackup($id);
-
- throw new RuntimeException("Couldn't make a backup, so nothing was changed: " . $exception->getMessage());
- }
-
- return $id;
- }
-
- private static function backupDirectory(string $backupId): ?string
- {
- if (preg_match('/^\d{8}-\d{6}_[A-Za-z0-9.-]{0,60}$/', $backupId) !== 1) {
- return null;
- }
-
- $directory = self::storagePath('backups/' . $backupId);
-
- return is_dir($directory) ? $directory : null;
- }
-
- private static function readMeta(string $directory): ?array
- {
- $raw = @file_get_contents($directory . '/meta.json');
- $meta = $raw !== false ? json_decode($raw, true) : null;
-
- return is_array($meta) && is_array($meta['files'] ?? null) ? $meta : null;
- }
-
- private static function markBackup(string $backupId, array $values): void
- {
- $directory = self::backupDirectory($backupId);
- $meta = $directory !== null ? self::readMeta($directory) : null;
- if ($meta !== null) {
- @file_put_contents($directory . '/meta.json', json_encode($values + $meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
- }
- }
-
- private static function copyInto(string $from, string $to): void
- {
- $directory = dirname($to);
- if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
- throw new RuntimeException("Couldn't create a folder for " . basename($to) . '.');
- }
-
- if (!@copy($from, $to)) {
- throw new RuntimeException("Couldn't copy " . basename($to) . '.');
- }
- }
-
- private static function writeFile(ZipArchive $zip, array $item): void
- {
- $target = $item['target'];
- $directory = dirname($target);
- if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
- throw new RuntimeException("Couldn't create the folder for " . $item['path'] . '.');
- }
-
- $source = $zip->getStream($item['zip']);
- if ($source === false) {
- throw new RuntimeException("Couldn't read " . $item['path'] . ' from the update.');
- }
-
- $temporary = $target . '.fpu-new';
- $output = @fopen($temporary, 'wb');
- if ($output === false) {
- fclose($source);
-
- throw new RuntimeException("Couldn't write " . $item['path'] . '. Check the file permissions on your server.');
- }
-
- $copied = stream_copy_to_stream($source, $output);
- fclose($source);
- $flushed = fclose($output);
- if ($copied === false || !$flushed) {
- @unlink($temporary);
-
- throw new RuntimeException("Couldn't write " . $item['path'] . '.');
- }
-
- @chmod($temporary, is_file($target) ? (fileperms($target) & 0777) : 0644);
-
- if (!@rename($temporary, $target)) {
- if (is_file($target)) {
- @unlink($target);
- }
- if (!@rename($temporary, $target)) {
- @unlink($temporary);
-
- throw new RuntimeException("Couldn't replace " . $item['path'] . '.');
- }
- }
-
- invalidate_php_cache($target);
-
- if (!hash_equals($item['hash'], (string) hash_file('sha256', $target))) {
- throw new RuntimeException($item['path'] . ' was not written correctly.');
- }
- }
-
- private static function rollback(array $plan, string $backupId): void
- {
- $directory = self::backupDirectory($backupId);
-
- foreach ($plan as $item) {
- @unlink($item['target'] . '.fpu-new');
-
- if ($item['status'] === 'added') {
- if (is_file($item['target'])) {
- @unlink($item['target']);
- }
- } elseif ($directory !== null && is_file($directory . '/files/' . $item['path'])) {
- @copy($directory . '/files/' . $item['path'], $item['target']);
- }
- invalidate_php_cache($item['target']);
- }
- }
-
- private static function log(string $message): void
- {
- @file_put_contents(self::storagePath('install.log'), '[' . date('c') . '] ' . $message . PHP_EOL, FILE_APPEND | LOCK_EX);
- }
-
- private static function releasePrefix(ZipArchive $zip): ?string
- {
- $prefix = null;
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex($i);
- if (basename($name) === 'version.txt') {
- $candidate = substr($name, 0, -strlen('version.txt'));
- if ($prefix === null || strlen($candidate) < strlen($prefix)) {
- $prefix = $candidate;
- }
- }
- }
-
- return $prefix;
- }
-
- private static function readRelease(ZipArchive $zip): ?array
- {
- $prefix = self::releasePrefix($zip);
- if ($prefix === null) {
- return null;
- }
-
- $entries = [];
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = (string) $zip->getNameIndex($i);
- if (str_ends_with($name, '/') || !str_starts_with($name, $prefix)) {
- continue;
- }
-
- $path = substr($name, strlen($prefix));
- if (self::isExcluded($path)) {
- continue;
- }
-
- $stat = $zip->statIndex($i);
- $size = (int) ($stat['size'] ?? 0);
- $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
- $entry = ['path' => $path];
-
- if ($size <= self::MAX_FILE_BYTES && in_array($extension, self::TEXT_EXTENSIONS, true)) {
- $content = (string) $zip->getFromIndex($i);
- if (!str_contains(substr($content, 0, 4096), "\0") && preg_match('//u', $content) === 1) {
- $entry['content'] = $content;
- $entries[] = $entry;
- continue;
- }
- }
-
- if ($size > self::MAX_HASHED_BYTES) {
- continue;
- }
-
- $hash = self::hashZipEntry($zip, $name);
- if ($hash === null) {
- continue;
- }
- $entry['sha256'] = $hash;
- $entries[] = $entry;
- }
-
- return $entries;
- }
-
- private static function isTrustedHost(string $url): bool
- {
- $host = parse_url($url, PHP_URL_HOST);
-
- return is_string($host) && strcasecmp($host, (string) parse_url(self::MANIFEST_URL, PHP_URL_HOST)) === 0;
- }
-
- private static function download(string $url, string $target): ?string
- {
- $handle = fopen($target, 'wb');
- if ($handle === false) {
- return "Couldn't save the downloaded update.";
- }
-
- $failure = null;
-
- if (function_exists('curl_init')) {
- $curl = curl_init($url);
- curl_setopt_array($curl, [
- CURLOPT_FILE => $handle,
- CURLOPT_CONNECTTIMEOUT => 5,
- CURLOPT_TIMEOUT => 120,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_MAXREDIRS => 3,
- CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
- CURLOPT_HTTPHEADER => ['User-Agent: FlatlyPage-Updater'],
- CURLOPT_NOPROGRESS => false,
- CURLOPT_PROGRESSFUNCTION => static fn($resource, $total, $downloaded) => $downloaded > self::MAX_ZIP_BYTES ? 1 : 0,
- ]);
- $ok = curl_exec($curl);
- $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
- $redirectedAway = !self::isTrustedHost((string) curl_getinfo($curl, CURLINFO_EFFECTIVE_URL));
- $curlError = curl_error($curl);
-
- if ($ok === false) {
- $failure = "Couldn't download the update" . ($curlError !== '' ? ': ' . $curlError : '.');
- } elseif ($redirectedAway) {
- $failure = 'The update server sent us to an address that isn\'t trusted, so nothing was downloaded.';
- } elseif ($status !== 200) {
- $failure = 'The update server returned an error (' . $status . '). Please try again later.';
- }
- } else {
- $context = stream_context_create(['http' => ['timeout' => 120, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: FlatlyPage-Updater']]);
- $source = @fopen($url, 'rb', false, $context);
- if ($source === false) {
- $failure = "Couldn't download the update.";
- } else {
- $written = 0;
- while (!feof($source)) {
- $chunk = (string) fread($source, 65536);
- $written += strlen($chunk);
- if ($written > self::MAX_ZIP_BYTES) {
- $failure = 'The update is too large to download.';
- break;
- }
- fwrite($handle, $chunk);
- }
- fclose($source);
-
- $statusLine = $http_response_header[0] ?? '';
- if ($failure === null && preg_match('#\s200\b#', $statusLine) !== 1) {
- $failure = "The update server didn't send the update. Please try again later.";
- }
- }
- }
-
- fclose($handle);
-
- if ($failure === null && filesize($target) > self::MAX_ZIP_BYTES) {
- $failure = 'The update is too large to download.';
- }
-
- return $failure;
- }
-
- public static function hunks(array $ops): array
- {
- $visible = [];
- foreach ($ops as $index => $op) {
- if ($op['type'] === 'eq') {
- continue;
- }
- $from = max(0, $index - self::CONTEXT_LINES);
- $to = min(count($ops) - 1, $index + self::CONTEXT_LINES);
- for ($cursor = $from; $cursor <= $to; $cursor++) {
- $visible[$cursor] = true;
- }
- }
-
- $hunks = [];
- $current = [];
- $previous = null;
- foreach (array_keys($visible) as $index) {
- if ($previous !== null && $index !== $previous + 1) {
- $hunks[] = $current;
- $current = [];
- }
- $current[] = $ops[$index];
- $previous = $index;
- }
- if ($current !== []) {
- $hunks[] = $current;
- }
-
- return $hunks;
- }
-
- public static function diff(array $old, array $new): array
- {
- $oldKeys = array_map([self::class, 'lineKey'], $old);
- $newKeys = array_map([self::class, 'lineKey'], $new);
- $oldCount = count($old);
- $newCount = count($new);
-
- $prefix = 0;
- while ($prefix < $oldCount && $prefix < $newCount && $oldKeys[$prefix] === $newKeys[$prefix]) {
- $prefix++;
- }
-
- $suffix = 0;
- while (
- $suffix < $oldCount - $prefix && $suffix < $newCount - $prefix
- && $oldKeys[$oldCount - 1 - $suffix] === $newKeys[$newCount - 1 - $suffix]
- ) {
- $suffix++;
- }
-
- $ops = [];
- for ($i = 0; $i < $prefix; $i++) {
- $ops[] = ['type' => 'eq', 'old' => $i + 1, 'new' => $i + 1, 'text' => $new[$i]];
- }
-
- $midOld = array_slice($oldKeys, $prefix, $oldCount - $prefix - $suffix);
- $midNew = array_slice($newKeys, $prefix, $newCount - $prefix - $suffix);
- foreach (self::diffMiddle($midOld, $midNew) as $step) {
- if ($step[0] === 'eq') {
- $ops[] = ['type' => 'eq', 'old' => $prefix + $step[1] + 1, 'new' => $prefix + $step[2] + 1, 'text' => $new[$prefix + $step[2]]];
- } elseif ($step[0] === 'del') {
- $ops[] = ['type' => 'del', 'old' => $prefix + $step[1] + 1, 'new' => null, 'text' => $old[$prefix + $step[1]]];
- } else {
- $ops[] = ['type' => 'add', 'old' => null, 'new' => $prefix + $step[2] + 1, 'text' => $new[$prefix + $step[2]]];
- }
- }
-
- for ($i = 0; $i < $suffix; $i++) {
- $ops[] = [
- 'type' => 'eq',
- 'old' => $oldCount - $suffix + $i + 1,
- 'new' => $newCount - $suffix + $i + 1,
- 'text' => $new[$newCount - $suffix + $i],
- ];
- }
-
- return self::markMoved($ops);
- }
-
- private static function lineKey(string $line): string
- {
- return trim((string) preg_replace('/\s+/', ' ', $line));
- }
-
- private static function diffMiddle(array $old, array $new): array
- {
- $n = count($old);
- $m = count($new);
-
- if ($n === 0 && $m === 0) {
- return [];
- }
-
- if (($n + 1) * ($m + 1) > self::MAX_DIFF_CELLS) {
- $steps = [];
- for ($i = 0; $i < $n; $i++) {
- $steps[] = ['del', $i, null];
- }
- for ($j = 0; $j < $m; $j++) {
- $steps[] = ['add', null, $j];
- }
-
- return $steps;
- }
-
- $table = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
- for ($i = $n - 1; $i >= 0; $i--) {
- for ($j = $m - 1; $j >= 0; $j--) {
- $table[$i][$j] = $old[$i] === $new[$j]
- ? $table[$i + 1][$j + 1] + 1
- : max($table[$i + 1][$j], $table[$i][$j + 1]);
- }
- }
-
- $steps = [];
- $i = 0;
- $j = 0;
- while ($i < $n && $j < $m) {
- if ($old[$i] === $new[$j]) {
- $steps[] = ['eq', $i, $j];
- $i++;
- $j++;
- } elseif ($table[$i + 1][$j] >= $table[$i][$j + 1]) {
- $steps[] = ['del', $i, null];
- $i++;
- } else {
- $steps[] = ['add', null, $j];
- $j++;
- }
- }
- for (; $i < $n; $i++) {
- $steps[] = ['del', $i, null];
- }
- for (; $j < $m; $j++) {
- $steps[] = ['add', null, $j];
- }
-
- return $steps;
- }
-
- private static function markMoved(array $ops): array
- {
- $removed = [];
- foreach ($ops as $index => $op) {
- if ($op['type'] === 'del') {
- $key = self::lineKey($op['text']);
- if (strlen($key) >= self::MIN_MOVED_LENGTH) {
- $removed[$key][] = $index;
- }
- }
- }
-
- foreach ($ops as $index => $op) {
- if ($op['type'] !== 'add') {
- continue;
- }
- $key = self::lineKey($op['text']);
- if (!empty($removed[$key])) {
- $partner = array_shift($removed[$key]);
- $ops[$partner]['type'] = 'moved_out';
- $ops[$index]['type'] = 'moved_in';
- }
- }
-
- return $ops;
- }
-
- private static function analyseFile(array $entry): ?array
- {
- $path = (string) ($entry['path'] ?? '');
- $localPath = self::resolveLocalPath($path);
- if ($localPath === null) {
- return null;
- }
-
- $exists = is_file($localPath);
-
- if ($exists && isset($entry['sha256']) && hash_equals($entry['sha256'], (string) hash_file('sha256', $localPath))) {
- return null;
- }
-
- $newContent = isset($entry['content']) && is_string($entry['content']) ? $entry['content'] : null;
-
- if ($exists && $newContent !== null && hash_equals(hash('sha256', $newContent), (string) hash_file('sha256', $localPath))) {
- return null;
- }
- $oldContent = $exists ? (filesize($localPath) <= self::MAX_FILE_BYTES ? (string) file_get_contents($localPath) : null) : '';
- $status = $exists ? 'modified' : 'added';
-
- $result = ['path' => $path, 'status' => $status, 'ops' => [], 'added' => 0, 'removed' => 0, 'moved' => 0, 'note' => ''];
-
- if ($newContent === null || $oldContent === null) {
- $result['note'] = 'Content is not available for line-by-line comparison.';
-
- return $result;
- }
-
- if (self::isBinary($oldContent)) {
- $result['note'] = 'Binary file.';
-
- return $result;
- }
-
- $ops = self::diff(self::splitLines($oldContent), self::splitLines($newContent));
- foreach ($ops as $op) {
- match ($op['type']) {
- 'add' => $result['added']++,
- 'del' => $result['removed']++,
- 'moved_in', 'moved_out' => $result['moved']++,
- default => null,
- };
- }
-
- if ($status === 'modified' && $result['added'] === 0 && $result['removed'] === 0 && $result['moved'] === 0) {
- $result['note'] = 'Whitespace-only changes.';
-
- return $result;
- }
-
- $result['ops'] = $ops;
-
- return $result;
- }
-
- private static function resolveLocalPath(string $path): ?string
- {
- if ($path === '' || strlen($path) > 240 || str_contains($path, "\0") || str_contains($path, '\\')) {
- return null;
- }
- if (str_starts_with($path, '/') || preg_match('#(^|/)\.\.(/|$)#', $path) === 1) {
- return null;
- }
-
- return BASE_DIR . '/' . $path;
- }
-
- private static function splitLines(string $content): array
- {
- if ($content === '') {
- return [];
- }
-
- $lines = preg_split('/\r\n|\r|\n/', $content) ?: [];
- if (end($lines) === '') {
- array_pop($lines);
- }
-
- return $lines;
- }
-
- private static function isBinary(string $content): bool
- {
- return str_contains(substr($content, 0, 4096), "\0");
- }
-
- private static function request(string $url): array
- {
- $headers = ['Accept: application/json', 'User-Agent: FlatlyPage-Updater'];
-
- if (function_exists('curl_init')) {
- $handle = curl_init($url);
- curl_setopt_array($handle, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_CONNECTTIMEOUT => 5,
- CURLOPT_TIMEOUT => 10,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_MAXREDIRS => 3,
- CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
- CURLOPT_HTTPHEADER => $headers,
- ]);
- $body = curl_exec($handle);
- $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
- $redirectedAway = !self::isTrustedHost((string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL));
- $failure = curl_error($handle);
-
- if ($body === false) {
- return [null, "Couldn't reach the update server" . ($failure !== '' ? ': ' . $failure : '.')];
- }
-
- if ($redirectedAway) {
- return [null, 'The update server sent us to an address that isn\'t trusted, so nothing was downloaded.'];
- }
- } else {
- $context = stream_context_create(['http' => [
- 'method' => 'GET',
- 'timeout' => 10,
- 'ignore_errors' => true,
- 'follow_location' => 1, 'max_redirects' => 3,
- 'header' => implode("\r\n", $headers),
- ]]);
- $body = @file_get_contents($url, false, $context);
- $status = 0;
- foreach ($http_response_header ?? [] as $line) {
- if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
- $status = (int) $match[1];
- }
- }
-
- if ($body === false) {
- return [null, "Couldn't reach the update server."];
- }
- }
-
- if ($status !== 200) {
- return [null, 'The update server returned an error (' . $status . '). Please try again later.'];
- }
-
- if (strlen($body) > self::MAX_RESPONSE_BYTES) {
- return [null, "The update server's answer was too large."];
- }
-
- return [$body, null];
- }
- }
-