WebOrbiton
v1.0.0.6

Publisium

1,382 lines · 50.0 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/version.php';
  6. ​
  7. final class Updater
  8. {
  9. public const MANIFEST_URL = 'https://weborbiton.eu/updater/publisium/';
  10. ​
  11. private const SETTING_KEY = 'updater_enabled';
  12. private const AUTO_KEY = 'updater_auto_enabled';
  13. private const AUTO_HTACCESS_KEY = 'updater_auto_htaccess';
  14. private const AUTO_LAST_RUN_KEY = 'updater_auto_last_run';
  15. private const AUTO_LAST_RESULT_KEY = 'updater_auto_last_result';
  16. private const MAX_RESPONSE_BYTES = 8388608;
  17. private const MAX_FILES = 300;
  18. private const MAX_FILE_BYTES = 524288;
  19. private const MAX_DIFF_CELLS = 400000;
  20. private const MIN_MOVED_LENGTH = 6;
  21. private const MAX_ZIP_BYTES = 67108864;
  22. private const MAX_HASHED_BYTES = 33554432;
  23. private const TEXT_EXTENSIONS = ['php', 'js', 'css', 'sql', 'txt', 'md', 'json', 'html', 'htm', 'svg', 'xml', 'htaccess', 'webmanifest'];
  24. private const EXCLUDED_PREFIXES = ['publisium-env/', 'media/', 'weborbiton.eu/', 'updater-files/', '.git/', 'CUSTOM SCRIPTS JS/', 'CUSTOM THEMES CSS/', '.priv/'];
  25. private const CONTEXT_LINES = 3;
  26. private const BACKUP_RETENTION_DAYS = 30;
  27. private const MERGED_FILES = ['translations/language.php'];
  28. private const PRESERVED_FILES = ['favicon.ico'];
  29. ​
  30. public static function lockedByConfig(): bool
  31. {
  32. return Config::get('UPDATER_DISABLED', '0') === '1';
  33. }
  34. ​
  35. public static function enabled(PDO $db): bool
  36. {
  37. if (self::lockedByConfig()) {
  38. return false;
  39. }
  40. ​
  41. try {
  42. $statement = $db->prepare('SELECT setting_value FROM site_settings WHERE setting_key = :key');
  43. $statement->execute(['key' => self::SETTING_KEY]);
  44. ​
  45. return $statement->fetchColumn() === '1';
  46. } catch (PDOException $exception) {
  47. return false;
  48. }
  49. }
  50. ​
  51. public static function setEnabled(PDO $db, bool $enabled): void
  52. {
  53. self::saveSetting($db, self::SETTING_KEY, $enabled ? '1' : '0');
  54. }
  55. ​
  56. public static function autoSettings(PDO $db): array
  57. {
  58. $settings = [
  59. self::AUTO_KEY => '0',
  60. self::AUTO_HTACCESS_KEY => '0',
  61. self::AUTO_LAST_RUN_KEY => '',
  62. self::AUTO_LAST_RESULT_KEY => '',
  63. ];
  64. ​
  65. try {
  66. $statement = $db->prepare('SELECT setting_key, setting_value FROM site_settings WHERE setting_key IN (?, ?, ?, ?)');
  67. $statement->execute(array_keys($settings));
  68. foreach ($statement->fetchAll(PDO::FETCH_KEY_PAIR) as $key => $value) {
  69. $settings[$key] = (string) $value;
  70. }
  71. } catch (PDOException $exception) {
  72. return ['enabled' => false, 'htaccess' => false, 'last_run' => '', 'last_result' => ''];
  73. }
  74. ​
  75. return [
  76. 'enabled' => $settings[self::AUTO_KEY] === '1',
  77. 'htaccess' => $settings[self::AUTO_HTACCESS_KEY] === '1',
  78. 'last_run' => $settings[self::AUTO_LAST_RUN_KEY],
  79. 'last_result' => $settings[self::AUTO_LAST_RESULT_KEY],
  80. ];
  81. }
  82. ​
  83. public static function setAutoSettings(PDO $db, bool $enabled, bool $updateHtaccess): void
  84. {
  85. self::saveSetting($db, self::AUTO_KEY, $enabled ? '1' : '0');
  86. self::saveSetting($db, self::AUTO_HTACCESS_KEY, $updateHtaccess ? '1' : '0');
  87. }
  88. ​
  89. public static function runAutomatic(PDO $db): array
  90. {
  91. if (!self::enabled($db)) {
  92. return ['ok' => true, 'installed' => false, 'message' => 'Updates are switched off.'];
  93. }
  94. ​
  95. $auto = self::autoSettings($db);
  96. if (!$auto['enabled']) {
  97. return ['ok' => true, 'installed' => false, 'message' => 'Automatic updates are off.'];
  98. }
  99. ​
  100. $currentVersion = AppVersion::current($db);
  101. $check = self::check($currentVersion);
  102. ​
  103. if (!$check['ok']) {
  104. $outcome = ['ok' => false, 'installed' => false, 'message' => 'We couldn’t check for updates: ' . $check['error']];
  105. } elseif ($check['status'] !== 'update') {
  106. $outcome = ['ok' => true, 'installed' => false, 'message' => 'No new version (you’re on ' . $currentVersion . ').'];
  107. } else {
  108. $install = self::install($currentVersion, $auto['htaccess']);
  109. $outcome = $install['ok']
  110. ? ['ok' => true, 'installed' => true, 'message' => 'Installed ' . $install['version'] . ' over ' . $currentVersion . ' (' . $install['installed'] . ' files, backup ' . $install['backup'] . ').']
  111. : ['ok' => false, 'installed' => false, 'message' => 'We couldn’t install ' . $check['remote_version'] . ': ' . $install['error']];
  112. }
  113. ​
  114. self::log('Automatic update: ' . $outcome['message']);
  115. self::saveSetting($db, self::AUTO_LAST_RUN_KEY, date('Y-m-d H:i:s'));
  116. self::saveSetting($db, self::AUTO_LAST_RESULT_KEY, mb_substr($outcome['message'], 0, 500));
  117. ​
  118. return $outcome;
  119. }
  120. ​
  121. private static function saveSetting(PDO $db, string $key, string $value): void
  122. {
  123. $db->exec(
  124. 'CREATE TABLE IF NOT EXISTS site_settings (
  125. setting_key VARCHAR(120) NOT NULL PRIMARY KEY,
  126. setting_value LONGTEXT NULL,
  127. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  128. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
  129. );
  130. ​
  131. $db->prepare(
  132. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
  133. )->execute(['key' => $key, 'value' => $value]);
  134. }
  135. ​
  136. public static function check(string $currentVersion): array
  137. {
  138. $url = self::MANIFEST_URL . '?version=' . rawurlencode($currentVersion);
  139. [$body, $error] = self::request($url);
  140. ​
  141. if ($body === null) {
  142. return ['ok' => false, 'error' => $error ?? 'The update server didn’t answer. Try again later.'];
  143. }
  144. ​
  145. $manifest = json_decode($body, true);
  146. if (!is_array($manifest) || !isset($manifest['version']) || !is_string($manifest['version']) || trim($manifest['version']) === '') {
  147. return ['ok' => false, 'error' => 'The update server sent back something we couldn’t read. Try again later.'];
  148. }
  149. ​
  150. $remoteVersion = trim($manifest['version']);
  151. $comparison = AppVersion::compare($remoteVersion, $currentVersion);
  152. ​
  153. $changelog = $manifest['changelog'] ?? [];
  154. if (is_string($changelog)) {
  155. $changelog = preg_split('/\R/', $changelog) ?: [];
  156. }
  157. $changelog = array_values(array_filter(array_map(
  158. static fn($item) => is_scalar($item) ? trim((string) $item) : '',
  159. is_array($changelog) ? $changelog : []
  160. ), static fn(string $item) => $item !== ''));
  161. ​
  162. return [
  163. 'ok' => true,
  164. 'remote_version' => $remoteVersion,
  165. 'released_at' => isset($manifest['released_at']) && is_scalar($manifest['released_at']) ? (string) $manifest['released_at'] : '',
  166. 'status' => $comparison > 0 ? 'update' : ($comparison === 0 ? 'current' : 'ahead'),
  167. 'changelog' => $changelog,
  168. 'sha256' => isset($manifest['sha256']) && is_string($manifest['sha256']) ? strtolower(trim($manifest['sha256'])) : '',
  169. 'files' => [],
  170. 'skipped' => 0,
  171. 'compared' => false,
  172. ];
  173. }
  174. ​
  175. public static function compare(string $currentVersion): array
  176. {
  177. $result = self::check($currentVersion);
  178. if (!$result['ok']) {
  179. return $result;
  180. }
  181. ​
  182. $problem = self::releaseProblem($result);
  183. if ($problem !== null) {
  184. return ['ok' => false, 'error' => $problem];
  185. }
  186. ​
  187. $prepared = self::prepareRelease($result);
  188. if (!$prepared['ok']) {
  189. return $prepared;
  190. }
  191. $temporary = $prepared['path'];
  192. ​
  193. try {
  194. $zip = new ZipArchive();
  195. if ($zip->open($temporary) !== true) {
  196. return ['ok' => false, 'error' => 'The download looks damaged. Try again.'];
  197. }
  198. ​
  199. $entries = self::readRelease($zip);
  200. $zip->close();
  201. ​
  202. if ($entries === null) {
  203. return ['ok' => false, 'error' => 'The download is missing its version file. Try again later.'];
  204. }
  205. } finally {
  206. @unlink($temporary);
  207. }
  208. ​
  209. $skipped = 0;
  210. foreach ($entries as $entry) {
  211. if (count($result['files']) >= self::MAX_FILES) {
  212. break;
  213. }
  214. $analysed = self::analyseFile($entry);
  215. if ($analysed === null) {
  216. $skipped++;
  217. continue;
  218. }
  219. $result['files'][] = $analysed;
  220. }
  221. $result['skipped'] = $skipped;
  222. $result['compared'] = true;
  223. ​
  224. return $result;
  225. }
  226. ​
  227. public static function storagePath(string $sub = ''): string
  228. {
  229. return dirname(__DIR__) . '/updater-files' . ($sub !== '' ? '/' . $sub : '');
  230. }
  231. ​
  232. public static function isHtaccess(string $path): bool
  233. {
  234. return strtolower(basename($path)) === '.htaccess';
  235. }
  236. ​
  237. public static function install(string $currentVersion, bool $updateHtaccess = true): array
  238. {
  239. ignore_user_abort(true);
  240. ​
  241. $result = self::check($currentVersion);
  242. if (!$result['ok']) {
  243. return $result;
  244. }
  245. if ($result['status'] === 'ahead') {
  246. return ['ok' => false, 'error' => 'Your site is already newer than the latest release.'];
  247. }
  248. ​
  249. $problem = self::releaseProblem($result);
  250. if ($problem !== null) {
  251. return ['ok' => false, 'error' => $problem];
  252. }
  253. ​
  254. $storageError = self::ensureStorage();
  255. if ($storageError !== null) {
  256. return ['ok' => false, 'error' => $storageError];
  257. }
  258. ​
  259. $lock = fopen(self::storagePath('install.lock'), 'c');
  260. if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
  261. return ['ok' => false, 'error' => 'An update is already running. Wait a moment and try again.'];
  262. }
  263. ​
  264. $temporary = null;
  265. $zip = null;
  266. ​
  267. try {
  268. $prepared = self::prepareRelease($result);
  269. if (!$prepared['ok']) {
  270. throw new RuntimeException($prepared['error']);
  271. }
  272. $temporary = $prepared['path'];
  273. ​
  274. $zip = new ZipArchive();
  275. if ($zip->open($temporary) !== true) {
  276. throw new RuntimeException('The download looks damaged. Try again.');
  277. }
  278. ​
  279. $prefix = self::releasePrefix($zip);
  280. if ($prefix === null) {
  281. throw new RuntimeException('The download is missing its version file. Try again later.');
  282. }
  283. if (trim((string) $zip->getFromName($prefix . 'version.txt')) !== $result['remote_version']) {
  284. throw new RuntimeException('The downloaded version doesn’t match what the server announced, so we stopped.');
  285. }
  286. ​
  287. $skippedHtaccess = 0;
  288. $plan = self::planInstall($zip, $prefix, $updateHtaccess, $skippedHtaccess);
  289. if ($plan === []) {
  290. throw new RuntimeException($skippedHtaccess > 0
  291. ? 'Only .htaccess files are different and you chose to keep yours, so there was nothing to install.'
  292. : 'Your files already match this version.');
  293. }
  294. ​
  295. $backupId = self::createBackup($plan, $currentVersion, $result['remote_version']);
  296. ​
  297. try {
  298. foreach ($plan as $item) {
  299. self::writeFile($zip, $item);
  300. }
  301. } catch (Throwable $failure) {
  302. self::rollback($plan, $backupId);
  303. self::log('Installation of ' . $result['remote_version'] . ' failed and was rolled back: ' . $failure->getMessage());
  304. throw new RuntimeException('The update didn’t work, so we undid every change: ' . $failure->getMessage());
  305. }
  306. ​
  307. AppVersion::current();
  308. self::markBackup($backupId, ['completed_at' => date('c')]);
  309. self::log('Installed ' . $result['remote_version'] . ' over ' . $currentVersion . ' (' . count($plan) . ' files, backup ' . $backupId . ').');
  310. self::pruneBackups();
  311. ​
  312. return ['ok' => true, 'version' => $result['remote_version'], 'installed' => count($plan), 'backup' => $backupId];
  313. } catch (Throwable $exception) {
  314. return ['ok' => false, 'error' => $exception->getMessage()];
  315. } finally {
  316. if ($zip instanceof ZipArchive) {
  317. @$zip->close();
  318. }
  319. if ($temporary !== null) {
  320. @unlink($temporary);
  321. }
  322. flock($lock, LOCK_UN);
  323. fclose($lock);
  324. }
  325. }
  326. ​
  327. public static function restore(string $backupId): array
  328. {
  329. ignore_user_abort(true);
  330. ​
  331. $directory = self::backupDirectory($backupId);
  332. $meta = $directory !== null ? self::readMeta($directory) : null;
  333. if ($meta === null) {
  334. return ['ok' => false, 'error' => 'We couldn’t find that backup.'];
  335. }
  336. ​
  337. $storageError = self::ensureStorage();
  338. if ($storageError !== null) {
  339. return ['ok' => false, 'error' => $storageError];
  340. }
  341. ​
  342. $lock = fopen(self::storagePath('install.lock'), 'c');
  343. if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
  344. return ['ok' => false, 'error' => 'An update is already running. Wait a moment and try again.'];
  345. }
  346. ​
  347. try {
  348. $items = [];
  349. foreach ($meta['files'] as $file) {
  350. $path = (string) ($file['path'] ?? '');
  351. $target = self::resolveLocalPath($path);
  352. if ($target === null || self::isExcluded($path)) {
  353. continue;
  354. }
  355. $status = ($file['status'] ?? '') === 'added' ? 'added' : 'modified';
  356. if ($status === 'modified' && !is_file($directory . '/files/' . $path)) {
  357. throw new RuntimeException('This backup is incomplete: ' . $path . ' is missing.');
  358. }
  359. $items[] = ['path' => $path, 'status' => $status, 'target' => $target];
  360. }
  361. ​
  362. foreach ($items as $item) {
  363. if ($item['status'] === 'added') {
  364. if (is_file($item['target'])) {
  365. @unlink($item['target']);
  366. }
  367. } else {
  368. self::copyInto($directory . '/files/' . $item['path'], $item['target']);
  369. }
  370. }
  371. ​
  372. AppVersion::current();
  373. self::markBackup($backupId, ['restored_at' => date('c')]);
  374. self::log('Restored backup ' . $backupId . ' (' . count($items) . ' files).');
  375. ​
  376. return ['ok' => true, 'restored' => count($items), 'version' => AppVersion::current()];
  377. } catch (Throwable $exception) {
  378. return ['ok' => false, 'error' => $exception->getMessage()];
  379. } finally {
  380. flock($lock, LOCK_UN);
  381. fclose($lock);
  382. }
  383. }
  384. ​
  385. public static function backups(): array
  386. {
  387. $list = [];
  388. foreach (glob(self::storagePath('backups') . '/*', GLOB_ONLYDIR) ?: [] as $directory) {
  389. $id = basename($directory);
  390. $meta = self::backupDirectory($id) !== null ? self::readMeta($directory) : null;
  391. if ($meta === null) {
  392. continue;
  393. }
  394. $list[] = [
  395. 'id' => $id,
  396. 'from_version' => (string) ($meta['from_version'] ?? ''),
  397. 'to_version' => (string) ($meta['to_version'] ?? ''),
  398. 'created_at' => (string) ($meta['created_at'] ?? ''),
  399. 'files' => count($meta['files']),
  400. 'completed' => !empty($meta['completed_at']),
  401. 'restored' => !empty($meta['restored_at']),
  402. ];
  403. }
  404. usort($list, static fn(array $a, array $b) => strcmp($b['id'], $a['id']));
  405. ​
  406. return $list;
  407. }
  408. ​
  409. public static function deleteBackup(string $backupId): bool
  410. {
  411. $directory = self::backupDirectory($backupId);
  412. if ($directory === null) {
  413. return false;
  414. }
  415. ​
  416. $items = new RecursiveIteratorIterator(
  417. new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
  418. RecursiveIteratorIterator::CHILD_FIRST
  419. );
  420. foreach ($items as $item) {
  421. $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
  422. }
  423. ​
  424. return @rmdir($directory);
  425. }
  426. ​
  427. /** Deletes backups older than BACKUP_RETENTION_DAYS. Returns how many were removed. */
  428. public static function pruneBackups(): int
  429. {
  430. $limit = time() - self::BACKUP_RETENTION_DAYS * 86400;
  431. $removed = 0;
  432. ​
  433. foreach (glob(self::storagePath('backups') . '/*', GLOB_ONLYDIR) ?: [] as $directory) {
  434. $id = basename($directory);
  435. if (self::backupDirectory($id) === null) {
  436. continue;
  437. }
  438. ​
  439. $meta = self::readMeta($directory);
  440. $created = $meta !== null && isset($meta['created_at']) ? strtotime((string) $meta['created_at']) : false;
  441. if ($created === false) {
  442. $created = (int) filemtime($directory);
  443. }
  444. ​
  445. if ($created < $limit && self::deleteBackup($id)) {
  446. $removed++;
  447. }
  448. }
  449. ​
  450. if ($removed > 0) {
  451. self::log('Removed ' . $removed . ' backup(s) older than ' . self::BACKUP_RETENTION_DAYS . ' days.');
  452. }
  453. ​
  454. return $removed;
  455. }
  456. ​
  457. private static function releaseProblem(array $manifest): ?string
  458. {
  459. if (!class_exists('ZipArchive')) {
  460. return 'Your server needs the PHP zip extension to install updates.';
  461. }
  462. ​
  463. if (preg_match('/^[0-9a-f]{64}$/', $manifest['sha256']) !== 1) {
  464. return 'The update server didn’t send a checksum, so we can’t trust this download.';
  465. }
  466. ​
  467. return null;
  468. }
  469. ​
  470. private static function ensureStorage(): ?string
  471. {
  472. foreach ([self::storagePath(), self::storagePath('backups'), self::storagePath('tmp')] as $directory) {
  473. if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) {
  474. return 'We couldn’t create the updater-files folder. Check the folder permissions on your server.';
  475. }
  476. }
  477. ​
  478. $guards = [
  479. 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",
  480. self::storagePath('index.html') => '',
  481. ];
  482. foreach ($guards as $path => $content) {
  483. if (!is_file($path)) {
  484. @file_put_contents($path, $content);
  485. }
  486. }
  487. ​
  488. return null;
  489. }
  490. ​
  491. private static function prepareRelease(array $manifest): array
  492. {
  493. $storageError = self::ensureStorage();
  494. if ($storageError !== null) {
  495. return ['ok' => false, 'error' => $storageError];
  496. }
  497. ​
  498. $temporary = tempnam(self::storagePath('tmp'), 'pbu');
  499. if ($temporary === false) {
  500. return ['ok' => false, 'error' => 'We couldn’t create a temporary file in updater-files/tmp. Check the folder permissions.'];
  501. }
  502. ​
  503. $error = self::download(self::MANIFEST_URL . '?download=1', $temporary);
  504. if ($error === null && !hash_equals($manifest['sha256'], (string) hash_file('sha256', $temporary))) {
  505. $error = 'The download doesn’t match its checksum, so we didn’t use it. Try again.';
  506. }
  507. ​
  508. if ($error !== null) {
  509. @unlink($temporary);
  510. ​
  511. return ['ok' => false, 'error' => $error];
  512. }
  513. ​
  514. return ['ok' => true, 'path' => $temporary];
  515. }
  516. ​
  517. private static function isPreserved(string $path, string $target): bool
  518. {
  519. return in_array($path, self::PRESERVED_FILES, true) && is_file($target);
  520. }
  521. ​
  522. private static function isExcluded(string $path): bool
  523. {
  524. foreach (self::EXCLUDED_PREFIXES as $excluded) {
  525. if (str_starts_with($path, $excluded)) {
  526. return true;
  527. }
  528. }
  529. ​
  530. return $path === 'changelog.txt' || str_ends_with($path, '.pbu-new');
  531. }
  532. ​
  533. private static function hashZipEntry(ZipArchive $zip, string $name): ?string
  534. {
  535. $stream = $zip->getStream($name);
  536. if ($stream === false) {
  537. return null;
  538. }
  539. ​
  540. $hash = hash_init('sha256');
  541. while (!feof($stream)) {
  542. hash_update($hash, (string) fread($stream, 65536));
  543. }
  544. fclose($stream);
  545. ​
  546. return hash_final($hash);
  547. }
  548. ​
  549. private static function planInstall(ZipArchive $zip, string $prefix, bool $updateHtaccess = true, int &$skippedHtaccess = 0): array
  550. {
  551. $plan = [];
  552. for ($i = 0; $i < $zip->numFiles; $i++) {
  553. $name = (string) $zip->getNameIndex($i);
  554. if (str_ends_with($name, '/') || !str_starts_with($name, $prefix)) {
  555. continue;
  556. }
  557. ​
  558. $path = substr($name, strlen($prefix));
  559. $target = self::resolveLocalPath($path);
  560. if ($target === null || self::isExcluded($path) || self::isPreserved($path, $target) || is_link($target) || is_dir($target)) {
  561. continue;
  562. }
  563. ​
  564. $stat = $zip->statIndex($i);
  565. if ((int) ($stat['size'] ?? 0) > self::MAX_HASHED_BYTES) {
  566. throw new RuntimeException('The file ' . $path . ' is too big to install.');
  567. }
  568. ​
  569. if (in_array($path, self::MERGED_FILES, true) && is_file($target)) {
  570. $local = (string) file_get_contents($target);
  571. $merged = self::mergeTranslations($local, (string) $zip->getFromIndex($i));
  572. if ($merged !== $local) {
  573. $plan[] = ['path' => $path, 'zip' => $name, 'target' => $target, 'hash' => hash('sha256', $merged), 'status' => 'modified', 'content' => $merged];
  574. }
  575. continue;
  576. }
  577. ​
  578. $newHash = self::hashZipEntry($zip, $name);
  579. if ($newHash === null) {
  580. throw new RuntimeException('The file ' . $path . ' couldn’t be read from the download.');
  581. }
  582. ​
  583. $exists = is_file($target);
  584. if ($exists && hash_equals($newHash, (string) hash_file('sha256', $target))) {
  585. continue;
  586. }
  587. ​
  588. if (!$updateHtaccess && self::isHtaccess($path)) {
  589. $skippedHtaccess++;
  590. continue;
  591. }
  592. ​
  593. $plan[] = ['path' => $path, 'zip' => $name, 'target' => $target, 'hash' => $newHash, 'status' => $exists ? 'modified' : 'added'];
  594. if (count($plan) > self::MAX_FILES * 4) {
  595. throw new RuntimeException('This update changes too many files to install safely here.');
  596. }
  597. }
  598. ​
  599. usort($plan, static fn(array $a, array $b) => ($a['path'] === 'version.txt') <=> ($b['path'] === 'version.txt'));
  600. ​
  601. return $plan;
  602. }
  603. ​
  604. private static function createBackup(array $plan, string $fromVersion, string $toVersion): string
  605. {
  606. $id = date('Ymd-His') . '_' . trim((string) preg_replace('/[^A-Za-z0-9.]+/', '-', $fromVersion), '-');
  607. $directory = self::storagePath('backups/' . $id);
  608. ​
  609. if (is_dir($directory) || !@mkdir($directory, 0750, true)) {
  610. throw new RuntimeException('We couldn’t create the backup folder. Check the folder permissions.');
  611. }
  612. ​
  613. try {
  614. foreach ($plan as $item) {
  615. if ($item['status'] === 'modified') {
  616. self::copyInto($item['target'], $directory . '/files/' . $item['path']);
  617. }
  618. }
  619. ​
  620. $meta = [
  621. 'from_version' => $fromVersion,
  622. 'to_version' => $toVersion,
  623. 'created_at' => date('c'),
  624. 'files' => array_map(static fn(array $item) => ['path' => $item['path'], 'status' => $item['status']], $plan),
  625. ];
  626. if (file_put_contents($directory . '/meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
  627. throw new RuntimeException('We couldn’t finish writing the backup.');
  628. }
  629. } catch (Throwable $exception) {
  630. self::deleteBackup($id);
  631. ​
  632. throw new RuntimeException('We couldn’t make a backup, so nothing was changed: ' . $exception->getMessage());
  633. }
  634. ​
  635. return $id;
  636. }
  637. ​
  638. private static function backupDirectory(string $backupId): ?string
  639. {
  640. if (preg_match('/^\d{8}-\d{6}_[A-Za-z0-9.-]{0,60}$/', $backupId) !== 1) {
  641. return null;
  642. }
  643. ​
  644. $directory = self::storagePath('backups/' . $backupId);
  645. ​
  646. return is_dir($directory) ? $directory : null;
  647. }
  648. ​
  649. private static function readMeta(string $directory): ?array
  650. {
  651. $raw = @file_get_contents($directory . '/meta.json');
  652. $meta = $raw !== false ? json_decode($raw, true) : null;
  653. ​
  654. return is_array($meta) && is_array($meta['files'] ?? null) ? $meta : null;
  655. }
  656. ​
  657. private static function markBackup(string $backupId, array $values): void
  658. {
  659. $directory = self::backupDirectory($backupId);
  660. $meta = $directory !== null ? self::readMeta($directory) : null;
  661. if ($meta !== null) {
  662. @file_put_contents($directory . '/meta.json', json_encode($values + $meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
  663. }
  664. }
  665. ​
  666. private static function copyInto(string $from, string $to): void
  667. {
  668. $directory = dirname($to);
  669. if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
  670. throw new RuntimeException('We couldn’t create a folder for ' . basename($to) . '.');
  671. }
  672. ​
  673. if (!@copy($from, $to)) {
  674. throw new RuntimeException('We couldn’t copy ' . basename($to) . '.');
  675. }
  676. }
  677. ​
  678. private static function writeFile(ZipArchive $zip, array $item): void
  679. {
  680. $target = $item['target'];
  681. $directory = dirname($target);
  682. if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
  683. throw new RuntimeException('We couldn’t create the folder for ' . $item['path'] . '.');
  684. }
  685. ​
  686. if (isset($item['content'])) {
  687. $source = fopen('php://temp', 'r+');
  688. fwrite($source, $item['content']);
  689. rewind($source);
  690. } else {
  691. $source = $zip->getStream($item['zip']);
  692. }
  693. if ($source === false) {
  694. throw new RuntimeException('We couldn’t read ' . $item['path'] . ' from the download.');
  695. }
  696. ​
  697. $temporary = $target . '.pbu-new';
  698. $output = @fopen($temporary, 'wb');
  699. if ($output === false) {
  700. fclose($source);
  701. ​
  702. throw new RuntimeException('We couldn’t write ' . $item['path'] . '. Check the file permissions.');
  703. }
  704. ​
  705. $copied = stream_copy_to_stream($source, $output);
  706. fclose($source);
  707. $flushed = fclose($output);
  708. if ($copied === false || !$flushed) {
  709. @unlink($temporary);
  710. ​
  711. throw new RuntimeException('We couldn’t write ' . $item['path'] . '.');
  712. }
  713. ​
  714. @chmod($temporary, is_file($target) ? (fileperms($target) & 0777) : 0644);
  715. ​
  716. if (!@rename($temporary, $target)) {
  717. if (is_file($target)) {
  718. @unlink($target);
  719. }
  720. if (!@rename($temporary, $target)) {
  721. @unlink($temporary);
  722. ​
  723. throw new RuntimeException('We couldn’t replace ' . $item['path'] . '.');
  724. }
  725. }
  726. ​
  727. if (!hash_equals($item['hash'], (string) hash_file('sha256', $target))) {
  728. throw new RuntimeException($item['path'] . ' was not written correctly.');
  729. }
  730. }
  731. ​
  732. private static function rollback(array $plan, string $backupId): void
  733. {
  734. $directory = self::backupDirectory($backupId);
  735. ​
  736. foreach ($plan as $item) {
  737. @unlink($item['target'] . '.pbu-new');
  738. ​
  739. if ($item['status'] === 'added') {
  740. if (is_file($item['target'])) {
  741. @unlink($item['target']);
  742. }
  743. } elseif ($directory !== null && is_file($directory . '/files/' . $item['path'])) {
  744. @copy($directory . '/files/' . $item['path'], $item['target']);
  745. }
  746. }
  747. }
  748. ​
  749. private static function log(string $message): void
  750. {
  751. @file_put_contents(self::storagePath('install.log'), '[' . date('c') . '] ' . $message . PHP_EOL, FILE_APPEND | LOCK_EX);
  752. }
  753. ​
  754. private static function mergeTranslations(string $local, string $release): string
  755. {
  756. $releaseParsed = self::parseTranslations($release);
  757. $localParsed = self::parseTranslations($local);
  758. if ($releaseParsed === null || $localParsed === null) {
  759. return $local;
  760. }
  761. ​
  762. $original = array_column($localParsed['entries'], 'value', 'key');
  763. $merged = $local;
  764. $newline = str_contains($local, "\r\n") ? "\r\n" : "\n";
  765. $releaseKeys = array_column($releaseParsed['entries'], 'key');
  766. ​
  767. foreach ($releaseParsed['entries'] as $index => $entry) {
  768. $current = self::parseTranslations($merged);
  769. if ($current === null) {
  770. return $local;
  771. }
  772. ​
  773. $present = array_column($current['entries'], null, 'key');
  774. if (isset($present[$entry['key']])) {
  775. continue;
  776. }
  777. ​
  778. $anchor = null;
  779. for ($before = $index - 1; $before >= 0; $before--) {
  780. if (isset($present[$releaseKeys[$before]])) {
  781. $anchor = $present[$releaseKeys[$before]];
  782. break;
  783. }
  784. }
  785. ​
  786. $line = ' ' . var_export($entry['key'], true) . ' => ' . var_export($entry['value'], true);
  787. ​
  788. if ($anchor === null) {
  789. $position = $current['start'];
  790. $insertion = $newline . $line . ',';
  791. } elseif ($anchor['comma']) {
  792. $position = $anchor['end'];
  793. $insertion = $newline . $line . ',';
  794. } else {
  795. $position = $anchor['end'];
  796. $insertion = ',' . $newline . $line;
  797. }
  798. ​
  799. $merged = substr($merged, 0, $position) . $insertion . substr($merged, $position);
  800. }
  801. ​
  802. $final = self::parseTranslations($merged);
  803. if ($final === null) {
  804. return $local;
  805. }
  806. ​
  807. $finalValues = array_column($final['entries'], 'value', 'key');
  808. foreach ($original as $key => $value) {
  809. if (!array_key_exists($key, $finalValues) || $finalValues[$key] !== $value) {
  810. return $local;
  811. }
  812. }
  813. foreach ($releaseKeys as $key) {
  814. if (!array_key_exists($key, $finalValues)) {
  815. return $local;
  816. }
  817. }
  818. ​
  819. return $merged;
  820. }
  821. ​
  822. private static function parseTranslations(string $source): ?array
  823. {
  824. try {
  825. $tokens = token_get_all($source, TOKEN_PARSE);
  826. } catch (Throwable $exception) {
  827. return null;
  828. }
  829. ​
  830. $entries = [];
  831. $offset = 0;
  832. $start = null;
  833. $sawReturn = false;
  834. $state = 0;
  835. $key = null;
  836. $value = null;
  837. $valueEnd = 0;
  838. ​
  839. foreach ($tokens as $token) {
  840. $text = is_array($token) ? $token[1] : $token;
  841. $offset += strlen($text);
  842. ​
  843. if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) {
  844. continue;
  845. }
  846. ​
  847. if ($start === null) {
  848. if (is_array($token) && $token[0] === T_RETURN) {
  849. $sawReturn = true;
  850. } elseif ($sawReturn && $text === '[') {
  851. $start = $offset;
  852. }
  853. continue;
  854. }
  855. ​
  856. if ($state === 0 && is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) {
  857. $key = self::unquote($text);
  858. $state = 1;
  859. } elseif ($state === 1 && is_array($token) && $token[0] === T_DOUBLE_ARROW) {
  860. $state = 2;
  861. } elseif ($state === 2 && is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) {
  862. $value = self::unquote($text);
  863. $valueEnd = $offset;
  864. $state = 3;
  865. } elseif ($state === 3 && $text === ',') {
  866. $entries[] = ['key' => $key, 'value' => $value, 'end' => $offset, 'comma' => true];
  867. $state = 0;
  868. } elseif ($state === 3 && $text === ']') {
  869. $entries[] = ['key' => $key, 'value' => $value, 'end' => $valueEnd, 'comma' => false];
  870. $state = 0;
  871. } elseif ($text === ']') {
  872. break;
  873. } else {
  874. $state = 0;
  875. }
  876. }
  877. ​
  878. return $start === null ? null : ['entries' => $entries, 'start' => $start];
  879. }
  880. ​
  881. private static function unquote(string $literal): string
  882. {
  883. $inner = substr($literal, 1, -1);
  884. ​
  885. if ($literal[0] === "'") {
  886. return (string) preg_replace_callback('/\\\\([\\\\\'])/', static fn(array $match) => $match[1], $inner);
  887. }
  888. ​
  889. return stripcslashes($inner);
  890. }
  891. ​
  892. private static function releasePrefix(ZipArchive $zip): ?string
  893. {
  894. $prefix = null;
  895. for ($i = 0; $i < $zip->numFiles; $i++) {
  896. $name = (string) $zip->getNameIndex($i);
  897. if (basename($name) === 'version.txt') {
  898. $candidate = substr($name, 0, -strlen('version.txt'));
  899. if ($prefix === null || strlen($candidate) < strlen($prefix)) {
  900. $prefix = $candidate;
  901. }
  902. }
  903. }
  904. ​
  905. return $prefix;
  906. }
  907. ​
  908. private static function readRelease(ZipArchive $zip): ?array
  909. {
  910. $prefix = self::releasePrefix($zip);
  911. ​
  912. if ($prefix === null) {
  913. return null;
  914. }
  915. ​
  916. $entries = [];
  917. for ($i = 0; $i < $zip->numFiles; $i++) {
  918. $name = (string) $zip->getNameIndex($i);
  919. if (str_ends_with($name, '/') || !str_starts_with($name, $prefix)) {
  920. continue;
  921. }
  922. ​
  923. $path = substr($name, strlen($prefix));
  924. if ($path === 'changelog.txt') {
  925. continue;
  926. }
  927. foreach (self::EXCLUDED_PREFIXES as $excluded) {
  928. if (str_starts_with($path, $excluded)) {
  929. continue 2;
  930. }
  931. }
  932. $preservedTarget = self::resolveLocalPath($path);
  933. if ($preservedTarget !== null && self::isPreserved($path, $preservedTarget)) {
  934. continue;
  935. }
  936. ​
  937. $stat = $zip->statIndex($i);
  938. $size = (int) ($stat['size'] ?? 0);
  939. $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
  940. $entry = ['path' => $path];
  941. ​
  942. if ($size <= self::MAX_FILE_BYTES && in_array($extension, self::TEXT_EXTENSIONS, true)) {
  943. $content = (string) $zip->getFromIndex($i);
  944. $localTarget = self::resolveLocalPath($path);
  945. if (in_array($path, self::MERGED_FILES, true) && $localTarget !== null && is_file($localTarget)) {
  946. $content = self::mergeTranslations((string) file_get_contents($localTarget), $content);
  947. }
  948. if (!str_contains(substr($content, 0, 4096), "\0") && preg_match('//u', $content) === 1) {
  949. $entry['content'] = $content;
  950. $entries[] = $entry;
  951. continue;
  952. }
  953. }
  954. ​
  955. if ($size > self::MAX_HASHED_BYTES) {
  956. continue;
  957. }
  958. ​
  959. $stream = $zip->getStream($name);
  960. if ($stream === false) {
  961. continue;
  962. }
  963. $hash = hash_init('sha256');
  964. while (!feof($stream)) {
  965. hash_update($hash, (string) fread($stream, 65536));
  966. }
  967. fclose($stream);
  968. $entry['sha256'] = hash_final($hash);
  969. $entries[] = $entry;
  970. }
  971. ​
  972. return $entries;
  973. }
  974. ​
  975. private static function isTrustedHost(string $url): bool
  976. {
  977. $host = parse_url($url, PHP_URL_HOST);
  978. ​
  979. return is_string($host) && strcasecmp($host, (string) parse_url(self::MANIFEST_URL, PHP_URL_HOST)) === 0;
  980. }
  981. ​
  982. private static function download(string $url, string $target): ?string
  983. {
  984. $handle = fopen($target, 'wb');
  985. if ($handle === false) {
  986. return 'We couldn’t write a temporary file.';
  987. }
  988. ​
  989. $failure = null;
  990. ​
  991. if (function_exists('curl_init')) {
  992. $curl = curl_init($url);
  993. curl_setopt_array($curl, [
  994. CURLOPT_FILE => $handle,
  995. CURLOPT_CONNECTTIMEOUT => 5,
  996. CURLOPT_TIMEOUT => 120,
  997. CURLOPT_FOLLOWLOCATION => true,
  998. CURLOPT_MAXREDIRS => 3,
  999. CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
  1000. CURLOPT_HTTPHEADER => ['User-Agent: Publisium-Updater'],
  1001. CURLOPT_NOPROGRESS => false,
  1002. CURLOPT_PROGRESSFUNCTION => static fn($resource, $total, $downloaded) => $downloaded > self::MAX_ZIP_BYTES ? 1 : 0,
  1003. ]);
  1004. $ok = curl_exec($curl);
  1005. $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
  1006. $redirectedAway = !self::isTrustedHost((string) curl_getinfo($curl, CURLINFO_EFFECTIVE_URL));
  1007. $curlError = curl_error($curl);
  1008. ​
  1009. if ($ok === false) {
  1010. $failure = 'We couldn’t download the update' . ($curlError !== '' ? ': ' . $curlError : '.');
  1011. } elseif ($redirectedAway) {
  1012. $failure = 'The update server pointed to an address we don’t trust, so we stopped.';
  1013. } elseif ($status !== 200) {
  1014. $failure = 'The update server responded with HTTP ' . $status . '.';
  1015. }
  1016. } else {
  1017. $context = stream_context_create(['http' => ['timeout' => 120, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: Publisium-Updater']]);
  1018. $source = @fopen($url, 'rb', false, $context);
  1019. if ($source === false) {
  1020. $failure = 'We couldn’t download the update.';
  1021. } else {
  1022. $written = 0;
  1023. while (!feof($source)) {
  1024. $chunk = (string) fread($source, 65536);
  1025. $written += strlen($chunk);
  1026. if ($written > self::MAX_ZIP_BYTES) {
  1027. $failure = 'The update is too big to download.';
  1028. break;
  1029. }
  1030. fwrite($handle, $chunk);
  1031. }
  1032. fclose($source);
  1033. ​
  1034. $statusLine = $http_response_header[0] ?? '';
  1035. if ($failure === null && preg_match('#\s200\b#', $statusLine) !== 1) {
  1036. $failure = 'The update server didn’t send the update.';
  1037. }
  1038. }
  1039. }
  1040. ​
  1041. fclose($handle);
  1042. ​
  1043. if ($failure === null && filesize($target) > self::MAX_ZIP_BYTES) {
  1044. $failure = 'The update is too big to download.';
  1045. }
  1046. ​
  1047. return $failure;
  1048. }
  1049. ​
  1050. public static function hunks(array $ops): array
  1051. {
  1052. $visible = [];
  1053. foreach ($ops as $index => $op) {
  1054. if ($op['type'] === 'eq') {
  1055. continue;
  1056. }
  1057. $from = max(0, $index - self::CONTEXT_LINES);
  1058. $to = min(count($ops) - 1, $index + self::CONTEXT_LINES);
  1059. for ($cursor = $from; $cursor <= $to; $cursor++) {
  1060. $visible[$cursor] = true;
  1061. }
  1062. }
  1063. ​
  1064. $hunks = [];
  1065. $current = [];
  1066. $previous = null;
  1067. foreach (array_keys($visible) as $index) {
  1068. if ($previous !== null && $index !== $previous + 1) {
  1069. $hunks[] = $current;
  1070. $current = [];
  1071. }
  1072. $current[] = $ops[$index];
  1073. $previous = $index;
  1074. }
  1075. if ($current !== []) {
  1076. $hunks[] = $current;
  1077. }
  1078. ​
  1079. return $hunks;
  1080. }
  1081. ​
  1082. public static function diff(array $old, array $new): array
  1083. {
  1084. $oldKeys = array_map([self::class, 'lineKey'], $old);
  1085. $newKeys = array_map([self::class, 'lineKey'], $new);
  1086. $oldCount = count($old);
  1087. $newCount = count($new);
  1088. ​
  1089. $prefix = 0;
  1090. while ($prefix < $oldCount && $prefix < $newCount && $oldKeys[$prefix] === $newKeys[$prefix]) {
  1091. $prefix++;
  1092. }
  1093. ​
  1094. $suffix = 0;
  1095. while (
  1096. $suffix < $oldCount - $prefix && $suffix < $newCount - $prefix
  1097. && $oldKeys[$oldCount - 1 - $suffix] === $newKeys[$newCount - 1 - $suffix]
  1098. ) {
  1099. $suffix++;
  1100. }
  1101. ​
  1102. $ops = [];
  1103. for ($i = 0; $i < $prefix; $i++) {
  1104. $ops[] = ['type' => 'eq', 'old' => $i + 1, 'new' => $i + 1, 'text' => $new[$i]];
  1105. }
  1106. ​
  1107. $midOld = array_slice($oldKeys, $prefix, $oldCount - $prefix - $suffix);
  1108. $midNew = array_slice($newKeys, $prefix, $newCount - $prefix - $suffix);
  1109. foreach (self::diffMiddle($midOld, $midNew) as $step) {
  1110. if ($step[0] === 'eq') {
  1111. $ops[] = ['type' => 'eq', 'old' => $prefix + $step[1] + 1, 'new' => $prefix + $step[2] + 1, 'text' => $new[$prefix + $step[2]]];
  1112. } elseif ($step[0] === 'del') {
  1113. $ops[] = ['type' => 'del', 'old' => $prefix + $step[1] + 1, 'new' => null, 'text' => $old[$prefix + $step[1]]];
  1114. } else {
  1115. $ops[] = ['type' => 'add', 'old' => null, 'new' => $prefix + $step[2] + 1, 'text' => $new[$prefix + $step[2]]];
  1116. }
  1117. }
  1118. ​
  1119. for ($i = 0; $i < $suffix; $i++) {
  1120. $ops[] = [
  1121. 'type' => 'eq',
  1122. 'old' => $oldCount - $suffix + $i + 1,
  1123. 'new' => $newCount - $suffix + $i + 1,
  1124. 'text' => $new[$newCount - $suffix + $i],
  1125. ];
  1126. }
  1127. ​
  1128. return self::markMoved($ops);
  1129. }
  1130. ​
  1131. private static function lineKey(string $line): string
  1132. {
  1133. return trim((string) preg_replace('/\s+/', ' ', $line));
  1134. }
  1135. ​
  1136. private static function diffMiddle(array $old, array $new): array
  1137. {
  1138. $n = count($old);
  1139. $m = count($new);
  1140. ​
  1141. if ($n === 0 && $m === 0) {
  1142. return [];
  1143. }
  1144. ​
  1145. if (($n + 1) * ($m + 1) > self::MAX_DIFF_CELLS) {
  1146. $steps = [];
  1147. for ($i = 0; $i < $n; $i++) {
  1148. $steps[] = ['del', $i, null];
  1149. }
  1150. for ($j = 0; $j < $m; $j++) {
  1151. $steps[] = ['add', null, $j];
  1152. }
  1153. ​
  1154. return $steps;
  1155. }
  1156. ​
  1157. $table = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
  1158. for ($i = $n - 1; $i >= 0; $i--) {
  1159. for ($j = $m - 1; $j >= 0; $j--) {
  1160. $table[$i][$j] = $old[$i] === $new[$j]
  1161. ? $table[$i + 1][$j + 1] + 1
  1162. : max($table[$i + 1][$j], $table[$i][$j + 1]);
  1163. }
  1164. }
  1165. ​
  1166. $steps = [];
  1167. $i = 0;
  1168. $j = 0;
  1169. while ($i < $n && $j < $m) {
  1170. if ($old[$i] === $new[$j]) {
  1171. $steps[] = ['eq', $i, $j];
  1172. $i++;
  1173. $j++;
  1174. } elseif ($table[$i + 1][$j] >= $table[$i][$j + 1]) {
  1175. $steps[] = ['del', $i, null];
  1176. $i++;
  1177. } else {
  1178. $steps[] = ['add', null, $j];
  1179. $j++;
  1180. }
  1181. }
  1182. for (; $i < $n; $i++) {
  1183. $steps[] = ['del', $i, null];
  1184. }
  1185. for (; $j < $m; $j++) {
  1186. $steps[] = ['add', null, $j];
  1187. }
  1188. ​
  1189. return $steps;
  1190. }
  1191. ​
  1192. private static function markMoved(array $ops): array
  1193. {
  1194. $removed = [];
  1195. foreach ($ops as $index => $op) {
  1196. if ($op['type'] === 'del') {
  1197. $key = self::lineKey($op['text']);
  1198. if (strlen($key) >= self::MIN_MOVED_LENGTH) {
  1199. $removed[$key][] = $index;
  1200. }
  1201. }
  1202. }
  1203. ​
  1204. foreach ($ops as $index => $op) {
  1205. if ($op['type'] !== 'add') {
  1206. continue;
  1207. }
  1208. $key = self::lineKey($op['text']);
  1209. if (!empty($removed[$key])) {
  1210. $partner = array_shift($removed[$key]);
  1211. $ops[$partner]['type'] = 'moved_out';
  1212. $ops[$index]['type'] = 'moved_in';
  1213. }
  1214. }
  1215. ​
  1216. return $ops;
  1217. }
  1218. ​
  1219. private static function analyseFile(array $entry): ?array
  1220. {
  1221. $path = isset($entry['path']) && is_string($entry['path']) ? trim($entry['path']) : '';
  1222. $localPath = self::resolveLocalPath($path);
  1223. if ($localPath === null) {
  1224. return null;
  1225. }
  1226. ​
  1227. $declared = isset($entry['status']) && is_string($entry['status']) ? strtolower($entry['status']) : '';
  1228. $exists = is_file($localPath);
  1229. ​
  1230. if ($exists && $declared !== 'deleted' && $declared !== 'removed' && isset($entry['sha256']) && is_string($entry['sha256'])
  1231. && hash_equals(strtolower($entry['sha256']), (string) hash_file('sha256', $localPath))) {
  1232. return null;
  1233. }
  1234. ​
  1235. $newContent = null;
  1236. if (isset($entry['content']) && is_string($entry['content'])) {
  1237. $newContent = $entry['content'];
  1238. } elseif (isset($entry['content_base64']) && is_string($entry['content_base64'])) {
  1239. $decoded = base64_decode($entry['content_base64'], true);
  1240. $newContent = $decoded === false ? null : $decoded;
  1241. }
  1242. ​
  1243. $isDeleted = $declared === 'deleted' || $declared === 'removed';
  1244. $oldContent = $exists && filesize($localPath) <= self::MAX_FILE_BYTES ? (string) file_get_contents($localPath) : null;
  1245. ​
  1246. if ($isDeleted) {
  1247. if (!$exists) {
  1248. return null;
  1249. }
  1250. $status = 'deleted';
  1251. $newContent = '';
  1252. } elseif (!$exists) {
  1253. $status = 'added';
  1254. $oldContent = '';
  1255. } else {
  1256. $status = 'modified';
  1257. }
  1258. ​
  1259. $result = ['path' => $path, 'status' => $status, 'ops' => [], 'added' => 0, 'removed' => 0, 'moved' => 0, 'note' => ''];
  1260. ​
  1261. if ($newContent === null || $oldContent === null || strlen($newContent) > self::MAX_FILE_BYTES) {
  1262. $result['note'] = 'This file can’t be compared line by line.';
  1263. ​
  1264. return $result;
  1265. }
  1266. ​
  1267. if (self::isBinary($newContent) || self::isBinary($oldContent)) {
  1268. $result['note'] = 'This isn’t a text file, so there’s nothing to show.';
  1269. ​
  1270. return $result;
  1271. }
  1272. ​
  1273. $ops = self::diff(self::splitLines($oldContent), self::splitLines($newContent));
  1274. foreach ($ops as $op) {
  1275. match ($op['type']) {
  1276. 'add' => $result['added']++,
  1277. 'del' => $result['removed']++,
  1278. 'moved_in', 'moved_out' => $result['moved']++,
  1279. default => null,
  1280. };
  1281. }
  1282. ​
  1283. if ($status === 'modified' && $result['added'] === 0 && $result['removed'] === 0 && $result['moved'] === 0) {
  1284. return null;
  1285. }
  1286. ​
  1287. $result['ops'] = $ops;
  1288. ​
  1289. return $result;
  1290. }
  1291. ​
  1292. private static function resolveLocalPath(string $path): ?string
  1293. {
  1294. if ($path === '' || strlen($path) > 240 || str_contains($path, "\0") || str_contains($path, '\\')) {
  1295. return null;
  1296. }
  1297. if (str_starts_with($path, '/') || preg_match('#(^|/)\.\.(/|$)#', $path) === 1) {
  1298. return null;
  1299. }
  1300. ​
  1301. return dirname(__DIR__) . '/' . $path;
  1302. }
  1303. ​
  1304. private static function splitLines(string $content): array
  1305. {
  1306. if ($content === '') {
  1307. return [];
  1308. }
  1309. ​
  1310. $lines = preg_split('/\r\n|\r|\n/', $content) ?: [];
  1311. if (end($lines) === '') {
  1312. array_pop($lines);
  1313. }
  1314. ​
  1315. return $lines;
  1316. }
  1317. ​
  1318. private static function isBinary(string $content): bool
  1319. {
  1320. return str_contains(substr($content, 0, 4096), "\0");
  1321. }
  1322. ​
  1323. private static function request(string $url): array
  1324. {
  1325. $headers = ['Accept: application/json', 'User-Agent: Publisium-Updater'];
  1326. ​
  1327. if (function_exists('curl_init')) {
  1328. $handle = curl_init($url);
  1329. curl_setopt_array($handle, [
  1330. CURLOPT_RETURNTRANSFER => true,
  1331. CURLOPT_CONNECTTIMEOUT => 5,
  1332. CURLOPT_TIMEOUT => 10,
  1333. CURLOPT_FOLLOWLOCATION => true,
  1334. CURLOPT_MAXREDIRS => 3,
  1335. CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
  1336. CURLOPT_HTTPHEADER => $headers,
  1337. ]);
  1338. $body = curl_exec($handle);
  1339. $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
  1340. $redirectedAway = !self::isTrustedHost((string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL));
  1341. $failure = curl_error($handle);
  1342. ​
  1343. if ($body === false) {
  1344. return [null, 'We couldn’t reach the update server' . ($failure !== '' ? ': ' . $failure : '.')];
  1345. }
  1346. ​
  1347. if ($redirectedAway) {
  1348. return [null, 'The update server pointed to an address we don’t trust, so we stopped.'];
  1349. }
  1350. } else {
  1351. $context = stream_context_create(['http' => [
  1352. 'method' => 'GET',
  1353. 'timeout' => 10,
  1354. 'ignore_errors' => true,
  1355. 'follow_location' => 1, 'max_redirects' => 3,
  1356. 'header' => implode("\r\n", $headers),
  1357. ]]);
  1358. $body = @file_get_contents($url, false, $context);
  1359. $status = 0;
  1360. foreach ($http_response_header ?? [] as $line) {
  1361. if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match) === 1) {
  1362. $status = (int) $match[1];
  1363. }
  1364. }
  1365. ​
  1366. if ($body === false) {
  1367. return [null, 'We couldn’t reach the update server. Try again later.'];
  1368. }
  1369. }
  1370. ​
  1371. if ($status !== 200) {
  1372. return [null, 'The update server responded with HTTP ' . $status . '.'];
  1373. }
  1374. ​
  1375. if (strlen($body) > self::MAX_RESPONSE_BYTES) {
  1376. return [null, 'The update server sent back too much data.'];
  1377. }
  1378. ​
  1379. return [$body, null];
  1380. }
  1381. }
  1382. ​