WebOrbiton
v3.0.0.2

FlatlyPage

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