WebOrbiton
v1.0.0.0

StocketBase

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