v1.0.0.0
StocketBase
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/version.php';
-
- final class Updater
- {
- public const MANIFEST_URL = 'https://weborbiton.eu/updater/stocketbase/';
-
- private const SETTING_KEY = 'updater_enabled';
- private const AUTO_ENABLED_KEY = 'updater_auto_enabled';
- private const AUTO_HTACCESS_KEY = 'updater_auto_htaccess';
- private const AUTO_TOKEN_KEY = 'updater_cron_token';
- private const AUTO_LAST_RUN_KEY = 'updater_auto_last_run';
- 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 PRESERVED_FILES = ['favicon.ico'];
- private const TEXT_EXTENSIONS =['php', 'js', 'css', 'sql', 'txt', 'md', 'json', 'html', 'htm', 'svg', 'xml', 'htaccess', 'webmanifest'];
- private const EXCLUDED_PREFIXES = ['stocketbase-env/', 'media/', 'weborbiton.eu/', 'updater-files/', 'private-downloads/', '.git/', '.priv/', 'CUSTOM SCRIPTS JS/', 'CUSTOM THEMES CSS/'];
- private const CONTEXT_LINES = 3;
- private const BACKUP_RETENTION_DAYS = 30;
- private const MERGED_FILES = ['translations/language.php'];
-
- public static function lockedByConfig(): bool
- {
- return Config::get('UPDATER_DISABLED', '0') === '1';
- }
-
- public static function enabled(PDO $db): bool
- {
- if (self::lockedByConfig()) {
- return false;
- }
-
- try {
- $statement = $db->prepare('SELECT setting_value FROM site_settings WHERE setting_key = :key');
- $statement->execute(['key' => self::SETTING_KEY]);
-
- return $statement->fetchColumn() === '1';
- } catch (PDOException $exception) {
- return false;
- }
- }
-
- public static function setEnabled(PDO $db, bool $enabled): void
- {
- $db->exec(
- 'CREATE TABLE IF NOT EXISTS site_settings (
- setting_key VARCHAR(120) NOT NULL PRIMARY KEY,
- setting_value LONGTEXT NULL,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
- );
-
- $db->prepare(
- 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
- )->execute(['key' => self::SETTING_KEY, 'value' => $enabled ? '1' : '0']);
-
- if (!$enabled) {
- self::writeSetting($db, self::AUTO_ENABLED_KEY, '0');
- }
- }
-
- public static function autoSettings(PDO $db): array
- {
- $lastRun = json_decode((string) self::readSetting($db, self::AUTO_LAST_RUN_KEY), true);
-
- return [
- 'enabled' => self::readSetting($db, self::AUTO_ENABLED_KEY) === '1',
- 'htaccess' => self::readSetting($db, self::AUTO_HTACCESS_KEY) === '1',
- 'token' => (string) self::readSetting($db, self::AUTO_TOKEN_KEY),
- 'last_run' => is_array($lastRun) ? $lastRun : null,
- ];
- }
-
- public static function saveAutoSettings(PDO $db, bool $enabled, bool $updateHtaccess): void
- {
- self::writeSetting($db, self::AUTO_ENABLED_KEY, $enabled ? '1' : '0');
- self::writeSetting($db, self::AUTO_HTACCESS_KEY, $updateHtaccess ? '1' : '0');
-
- if ($enabled && (string) self::readSetting($db, self::AUTO_TOKEN_KEY) === '') {
- self::regenerateCronToken($db);
- }
- }
-
- public static function regenerateCronToken(PDO $db): string
- {
- $token = bin2hex(random_bytes(24));
- self::writeSetting($db, self::AUTO_TOKEN_KEY, $token);
-
- return $token;
- }
-
- public static function runAutomatic(PDO $db): array
- {
- if (self::lockedByConfig()) {
- return self::recordAutoRun($db, 'disabled', 'The updater is disabled in the server configuration (UPDATER_DISABLED=1).');
- }
- if (!self::enabled($db)) {
- return self::recordAutoRun($db, 'disabled', 'The updater is turned off.');
- }
-
- $auto = self::autoSettings($db);
- if (!$auto['enabled']) {
- return self::recordAutoRun($db, 'disabled', 'Automatic updates are turned off.');
- }
-
- $currentVersion = AppVersion::current($db);
- $check = self::check($currentVersion);
- if (!$check['ok']) {
- return self::recordAutoRun($db, 'error', $check['error']);
- }
-
- if ($check['status'] !== 'update') {
- return self::recordAutoRun($db, 'current', 'No update available (installed version ' . $currentVersion . ').');
- }
-
- $outcome = self::install($currentVersion, $auto['htaccess']);
- if (!$outcome['ok']) {
- self::log('Automatic update to ' . $check['remote_version'] . ' failed: ' . $outcome['error']);
-
- return self::recordAutoRun($db, 'error', $outcome['error']);
- }
-
- self::log('Automatic update installed ' . $outcome['version'] . ' without human confirmation (backup ' . $outcome['backup'] . ').');
-
- return self::recordAutoRun(
- $db,
- 'installed',
- 'Updated ' . $currentVersion . ' to ' . $outcome['version'] . ' (' . $outcome['installed'] . ' files). Backup: ' . $outcome['backup'] . '.'
- );
- }
-
- private static function recordAutoRun(PDO $db, string $status, string $message): array
- {
- $entry = ['status' => $status, 'message' => $message, 'at' => date('c')];
- try {
- self::writeSetting($db, self::AUTO_LAST_RUN_KEY, (string) json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
- } catch (PDOException $exception) {
- self::log('Could not record the automatic update result: ' . $exception->getMessage());
- }
-
- return $entry;
- }
-
- private static function readSetting(PDO $db, string $key): ?string
- {
- try {
- $statement = $db->prepare('SELECT setting_value FROM site_settings WHERE setting_key = :key');
- $statement->execute(['key' => $key]);
- $value = $statement->fetchColumn();
-
- return $value === false ? null : (string) $value;
- } catch (PDOException $exception) {
- return null;
- }
- }
-
- private static function writeSetting(PDO $db, string $key, string $value): void
- {
- $db->prepare(
- 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
- )->execute(['key' => $key, 'value' => $value]);
- }
-
- public static function check(string $currentVersion): array
- {
- $url = self::MANIFEST_URL . '?version=' . rawurlencode($currentVersion);
- [$body, $error] = self::request($url);
-
- 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']) || trim($manifest['version']) === '') {
- return ['ok' => false, 'error' => 'The update server returned an invalid response.'];
- }
-
- $remoteVersion = trim($manifest['version']);
- $comparison = AppVersion::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 release is not a valid zip archive.'];
- }
-
- $entries = self::readRelease($zip);
- $zip->close();
-
- if ($entries === null) {
- return ['ok' => false, 'error' => 'The downloaded release does not contain version.txt.'];
- }
- } 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 storagePath(string $sub = ''): string
- {
- return dirname(__DIR__) . '/updater-files' . ($sub !== '' ? '/' . $sub : '');
- }
-
- public static function isHtaccess(string $path): bool
- {
- return strtolower(basename($path)) === '.htaccess';
- }
-
- public static function install(string $currentVersion, bool $updateHtaccess = true): array
- {
- ignore_user_abort(true);
-
- $result = self::check($currentVersion);
- if (!$result['ok']) {
- return $result;
- }
- if ($result['status'] === 'ahead') {
- return ['ok' => false, 'error' => 'This installation is newer than the published 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 installation is already running.'];
- }
-
- $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 release is not a valid zip archive.');
- }
-
- $prefix = self::releasePrefix($zip);
- if ($prefix === null) {
- throw new RuntimeException('The downloaded release does not contain version.txt.');
- }
- if (trim((string) $zip->getFromName($prefix . 'version.txt')) !== $result['remote_version']) {
- throw new RuntimeException('The release version does not match the version announced by the server.');
- }
-
- $skippedHtaccess = 0;
- $plan = self::planInstall($zip, $prefix, $updateHtaccess, $skippedHtaccess);
- if ($plan === []) {
- throw new RuntimeException($skippedHtaccess > 0
- ? 'Only .htaccess files differ from the release and they were excluded, so nothing was installed.'
- : 'Your files already match the release.');
- }
-
- $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('Installation failed and every change was rolled back: ' . $failure->getMessage());
- }
-
- AppVersion::current();
- self::markBackup($backupId, ['completed_at' => date('c')]);
- self::log('Installed ' . $result['remote_version'] . ' over ' . $currentVersion . ' (' . count($plan) . ' files, backup ' . $backupId . ').');
- self::pruneBackups();
-
- 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' => 'Backup not found.'];
- }
-
- $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 installation is already running.'];
- }
-
- 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('The 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']);
- }
- }
-
- AppVersion::current();
- self::markBackup($backupId, ['restored_at' => date('c')]);
- self::log('Restored backup ' . $backupId . ' (' . count($items) . ' files).');
-
- return ['ok' => true, 'restored' => count($items), 'version' => AppVersion::current()];
- } 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);
- }
-
- public static function pruneBackups(): int
- {
- $limit = time() - self::BACKUP_RETENTION_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 = $meta !== null && isset($meta['created_at']) ? strtotime((string) $meta['created_at']) : false;
- if ($created === false) {
- $created = (int) filemtime($directory);
- }
-
- if ($created < $limit && self::deleteBackup($id)) {
- $removed++;
- }
- }
-
- if ($removed > 0) {
- self::log('Removed ' . $removed . ' backup(s) older than ' . self::BACKUP_RETENTION_DAYS . ' days.');
- }
-
- return $removed;
- }
-
- private static function releaseProblem(array $manifest): ?string
- {
- if (!class_exists('ZipArchive')) {
- return 'The PHP zip extension is required to read the release.';
- }
-
- if (preg_match('/^[0-9a-f]{64}$/', $manifest['sha256']) !== 1) {
- return 'The update server did not provide a checksum for the release.';
- }
-
- 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 'Could not create the updater-files folder. Check the write permissions of the site directory.';
- }
- }
-
- $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'), 'pbu');
- if ($temporary === false) {
- return ['ok' => false, 'error' => 'Could not create a temporary file in updater-files/tmp.'];
- }
-
- $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
- {
- foreach (self::EXCLUDED_PREFIXES as $excluded) {
- if (str_starts_with($path, $excluded)) {
- return true;
- }
- }
-
- return $path === 'changelog.txt' || str_ends_with($path, '.pbu-new');
- }
-
- private static function isPreserved(string $path, string $target): bool
- {
- return in_array($path, self::PRESERVED_FILES, true) && is_file($target);
- }
-
- 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 $updateHtaccess = true, int &$skippedHtaccess = 0): 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) || self::isPreserved($path, $target)) {
- 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.');
- }
-
- if (in_array($path, self::MERGED_FILES, true) && is_file($target)) {
- $local = (string) file_get_contents($target);
- $merged = self::mergeTranslations($local, (string) $zip->getFromIndex($i));
- if ($merged !== $local) {
- $plan[] = ['path' => $path, 'zip' => $name, 'target' => $target, 'hash' => hash('sha256', $merged), 'status' => 'modified', 'content' => $merged];
- }
- continue;
- }
-
- $newHash = self::hashZipEntry($zip, $name);
- if ($newHash === null) {
- throw new RuntimeException('The file ' . $path . ' could not be read from the release.');
- }
-
- $exists = is_file($target);
- if ($exists && hash_equals($newHash, (string) hash_file('sha256', $target))) {
- continue;
- }
-
- if (!$updateHtaccess && self::isHtaccess($path)) {
- $skippedHtaccess++;
- continue;
- }
-
- $plan[] = ['path' => $path, 'zip' => $name, 'target' => $target, 'hash' => $newHash, 'status' => $exists ? 'modified' : 'added'];
- if (count($plan) > self::MAX_FILES * 4) {
- throw new RuntimeException('The release changes too many files.');
- }
- }
-
- 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('Could not 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('Could not write the backup description.');
- }
- } catch (Throwable $exception) {
- self::deleteBackup($id);
-
- throw new RuntimeException('The backup could not be created, 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('Could not create a folder for ' . basename($to) . '.');
- }
-
- if (!@copy($from, $to)) {
- throw new RuntimeException('Could not 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('Could not create the folder for ' . $item['path'] . '.');
- }
-
- if (isset($item['content'])) {
- $source = fopen('php://temp', 'r+');
- fwrite($source, $item['content']);
- rewind($source);
- } else {
- $source = $zip->getStream($item['zip']);
- }
- if ($source === false) {
- throw new RuntimeException('Could not read ' . $item['path'] . ' from the release.');
- }
-
- $temporary = $target . '.pbu-new';
- $output = @fopen($temporary, 'wb');
- if ($output === false) {
- fclose($source);
-
- throw new RuntimeException('Could not write ' . $item['path'] . '. Check file permissions.');
- }
-
- $copied = stream_copy_to_stream($source, $output);
- fclose($source);
- $flushed = fclose($output);
- if ($copied === false || !$flushed) {
- @unlink($temporary);
-
- throw new RuntimeException('Could not 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('Could not replace ' . $item['path'] . '.');
- }
- }
-
- 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'] . '.pbu-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']);
- }
- }
- }
-
- 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 mergeTranslations(string $local, string $release): string
- {
- $releaseParsed = self::parseTranslations($release);
- $localParsed = self::parseTranslations($local);
- if ($releaseParsed === null || $localParsed === null) {
- return $local;
- }
-
- $original = array_column($localParsed['entries'], 'value', 'key');
- $merged = $local;
- $newline = str_contains($local, "\r\n") ? "\r\n" : "\n";
- $releaseKeys = array_column($releaseParsed['entries'], 'key');
-
- foreach ($releaseParsed['entries'] as $index => $entry) {
- $current = self::parseTranslations($merged);
- if ($current === null) {
- return $local;
- }
-
- $present = array_column($current['entries'], null, 'key');
- if (isset($present[$entry['key']])) {
- continue;
- }
-
- $anchor = null;
- for ($before = $index - 1; $before >= 0; $before--) {
- if (isset($present[$releaseKeys[$before]])) {
- $anchor = $present[$releaseKeys[$before]];
- break;
- }
- }
-
- $line = ' ' . var_export($entry['key'], true) . ' => ' . var_export($entry['value'], true);
-
- if ($anchor === null) {
- $position = $current['start'];
- $insertion = $newline . $line . ',';
- } elseif ($anchor['comma']) {
- $position = $anchor['end'];
- $insertion = $newline . $line . ',';
- } else {
- $position = $anchor['end'];
- $insertion = ',' . $newline . $line;
- }
-
- $merged = substr($merged, 0, $position) . $insertion . substr($merged, $position);
- }
-
- $final = self::parseTranslations($merged);
- if ($final === null) {
- return $local;
- }
-
- $finalValues = array_column($final['entries'], 'value', 'key');
- foreach ($original as $key => $value) {
- if (!array_key_exists($key, $finalValues) || $finalValues[$key] !== $value) {
- return $local;
- }
- }
- foreach ($releaseKeys as $key) {
- if (!array_key_exists($key, $finalValues)) {
- return $local;
- }
- }
-
- return $merged;
- }
-
- private static function parseTranslations(string $source): ?array
- {
- try {
- $tokens = token_get_all($source, TOKEN_PARSE);
- } catch (Throwable $exception) {
- return null;
- }
-
- $entries = [];
- $offset = 0;
- $start = null;
- $sawReturn = false;
- $state = 0;
- $key = null;
- $value = null;
- $valueEnd = 0;
-
- foreach ($tokens as $token) {
- $text = is_array($token) ? $token[1] : $token;
- $offset += strlen($text);
-
- if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
- continue;
- }
-
- if ($start === null) {
- if (is_array($token) && $token[0] === T_RETURN) {
- $sawReturn = true;
- } elseif ($sawReturn && $text === '[') {
- $start = $offset;
- }
- continue;
- }
-
- if ($state === 0 && is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) {
- $key = self::unquote($text);
- $state = 1;
- } elseif ($state === 1 && is_array($token) && $token[0] === T_DOUBLE_ARROW) {
- $state = 2;
- } elseif ($state === 2 && is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) {
- $value = self::unquote($text);
- $valueEnd = $offset;
- $state = 3;
- } elseif ($state === 3 && $text === ',') {
- $entries[] = ['key' => $key, 'value' => $value, 'end' => $offset, 'comma' => true];
- $state = 0;
- } elseif ($state === 3 && $text === ']') {
- $entries[] = ['key' => $key, 'value' => $value, 'end' => $valueEnd, 'comma' => false];
- $state = 0;
- } elseif ($text === ']') {
- break;
- } else {
- $state = 0;
- }
- }
-
- return $start === null ? null : ['entries' => $entries, 'start' => $start];
- }
-
- private static function unquote(string $literal): string
- {
- $inner = substr($literal, 1, -1);
-
- if ($literal[0] === "'") {
- return (string) preg_replace_callback('/\\\\([\\\\\'])/', static fn(array $match) => $match[1], $inner);
- }
-
- return stripcslashes($inner);
- }
-
- 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 ($path === 'changelog.txt') {
- continue;
- }
- foreach (self::EXCLUDED_PREFIXES as $excluded) {
- if (str_starts_with($path, $excluded)) {
- continue 2;
- }
- }
- $preservedTarget = self::resolveLocalPath($path);
- if ($preservedTarget !== null && self::isPreserved($path, $preservedTarget)) {
- 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);
- $localTarget = self::resolveLocalPath($path);
- if (in_array($path, self::MERGED_FILES, true) && $localTarget !== null && is_file($localTarget)) {
- $content = self::mergeTranslations((string) file_get_contents($localTarget), $content);
- }
- 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;
- }
-
- $stream = $zip->getStream($name);
- if ($stream === false) {
- continue;
- }
- $hash = hash_init('sha256');
- while (!feof($stream)) {
- hash_update($hash, (string) fread($stream, 65536));
- }
- fclose($stream);
- $entry['sha256'] = hash_final($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 'Could not write the temporary file.';
- }
-
- $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: StocketBase-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 = 'Could not download the release' . ($curlError !== '' ? ': ' . $curlError : '.');
- } elseif ($redirectedAway) {
- $failure = 'The update server redirected to an untrusted address.';
- } elseif ($status !== 200) {
- $failure = 'The update server responded with HTTP ' . $status . '.';
- }
- } else {
- $context = stream_context_create(['http' => ['timeout' => 120, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: StocketBase-Updater']]);
- $source = @fopen($url, 'rb', false, $context);
- if ($source === false) {
- $failure = 'Could not download the release.';
- } else {
- $written = 0;
- while (!feof($source)) {
- $chunk = (string) fread($source, 65536);
- $written += strlen($chunk);
- if ($written > self::MAX_ZIP_BYTES) {
- $failure = 'The release is too large.';
- break;
- }
- fwrite($handle, $chunk);
- }
- fclose($source);
-
- $statusLine = $http_response_header[0] ?? '';
- if ($failure === null && preg_match('#\s200\b#', $statusLine) !== 1) {
- $failure = 'The update server did not return the release.';
- }
- }
- }
-
- fclose($handle);
-
- if ($failure === null && filesize($target) > self::MAX_ZIP_BYTES) {
- $failure = 'The release is too large.';
- }
-
- 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 = isset($entry['path']) && is_string($entry['path']) ? trim($entry['path']) : '';
- $localPath = self::resolveLocalPath($path);
- if ($localPath === null) {
- return null;
- }
-
- $declared = isset($entry['status']) && is_string($entry['status']) ? strtolower($entry['status']) : '';
- $exists = is_file($localPath);
-
- if ($exists && $declared !== 'deleted' && $declared !== 'removed' && isset($entry['sha256']) && is_string($entry['sha256'])
- && hash_equals(strtolower($entry['sha256']), (string) hash_file('sha256', $localPath))) {
- return null;
- }
-
- $newContent = null;
- if (isset($entry['content']) && is_string($entry['content'])) {
- $newContent = $entry['content'];
- } elseif (isset($entry['content_base64']) && is_string($entry['content_base64'])) {
- $decoded = base64_decode($entry['content_base64'], true);
- $newContent = $decoded === false ? null : $decoded;
- }
-
- $isDeleted = $declared === 'deleted' || $declared === 'removed';
- $oldContent = $exists && filesize($localPath) <= self::MAX_FILE_BYTES ? (string) file_get_contents($localPath) : null;
-
- if ($isDeleted) {
- if (!$exists) {
- return null;
- }
- $status = 'deleted';
- $newContent = '';
- } elseif (!$exists) {
- $status = 'added';
- $oldContent = '';
- } else {
- $status = 'modified';
- }
-
- $result = ['path' => $path, 'status' => $status, 'ops' => [], 'added' => 0, 'removed' => 0, 'moved' => 0, 'note' => ''];
-
- if ($newContent === null || $oldContent === null || strlen($newContent) > self::MAX_FILE_BYTES) {
- $result['note'] = 'Content is not available for line-by-line comparison.';
-
- return $result;
- }
-
- if (self::isBinary($newContent) || 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) {
- return null;
- }
-
- $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 dirname(__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: StocketBase-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, 'Could not reach the update server' . ($failure !== '' ? ': ' . $failure : '.')];
- }
-
- if ($redirectedAway) {
- return [null, 'The update server redirected to an untrusted address.'];
- }
- } 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, 'Could not reach the update server.'];
- }
- }
-
- if ($status !== 200) {
- return [null, 'The update server responded with HTTP ' . $status . '.'];
- }
-
- if (strlen($body) > self::MAX_RESPONSE_BYTES) {
- return [null, 'The update server response is too large.'];
- }
-
- return [$body, null];
- }
- }
-