WebOrbiton
v3.0.0.0

FlatlyPage

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