WebOrbiton
v1.3.0

AtheraStudio SyncDrop

556 lines · 24.8 KB
  1. <?php
  2. declare(strict_types=1);
  3. ​
  4. defined('SD_BOOT') || exit;
  5. ​
  6. const SD_UPDATE_URL = 'https://weborbiton.eu/updater/syncdrop/';
  7. const SD_UPDATE_KEY = 'a2b291a0aa90f059f5ff35b3096f534c25698d9481d965c358ac2639345f9769';
  8. const SD_UPDATE_PRODUCT = 'syncdrop';
  9. const SD_UPDATE_PREFIX = 'syncdrop/';
  10. const SD_UPDATE_TTL = 900;
  11. const SD_UPDATE_MAX_ZIP = 33554432;
  12. const SD_UPDATE_MAX_FILE = 16777216;
  13. const SD_UPDATE_MAX_FILES = 500;
  14. const SD_UPDATE_KEEP = 5;
  15. const SD_UPDATE_DIRS = ['app', 'assets', 'translate'];
  16. const SD_UPDATE_PROTECTED = ['config.php', '.syncdrop-id'];
  17. ​
  18. function sd_update_missing(): ?string
  19. {
  20. if (!class_exists('ZipArchive')) return 'update_need_zip';
  21. if (!function_exists('sodium_crypto_sign_verify_detached')) return 'update_need_sodium';
  22. if (!function_exists('curl_init') && !(filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL) && extension_loaded('openssl'))) return 'update_need_http';
  23. return null;
  24. }
  25. ​
  26. function sd_update_version_ok(mixed $version): bool
  27. {
  28. return is_string($version) && preg_match('/^\d{1,4}(\.\d{1,4}){1,3}$/', $version) === 1;
  29. }
  30. ​
  31. function sd_update_newer(mixed $version): bool
  32. {
  33. return sd_update_version_ok($version) && version_compare($version, SD_VERSION, '>');
  34. }
  35. ​
  36. function sd_update_fetch(string $url, ?string $file, int $max): array
  37. {
  38. if (!str_starts_with($url, 'https://')) return [null, 'update_err_network'];
  39. $out = null;
  40. if ($file !== null) {
  41. $out = @fopen($file, 'wb');
  42. if ($out === false) return [null, 'update_err_storage'];
  43. }
  44. $body = '';
  45. $size = 0;
  46. $over = false;
  47. $write = static function ($handle, string $chunk) use (&$body, &$size, &$over, $out, $max): int {
  48. $size += strlen($chunk);
  49. if ($size > $max) {
  50. $over = true;
  51. return 0;
  52. }
  53. if ($out !== null) fwrite($out, $chunk);
  54. else $body .= $chunk;
  55. return strlen($chunk);
  56. };
  57. $agent = 'AtheraStudio-SyncDrop/' . SD_VERSION;
  58. $accept = $file !== null ? 'application/zip' : 'application/json';
  59. $status = 0;
  60. $ok = false;
  61. if (function_exists('curl_init')) {
  62. $ch = curl_init($url);
  63. curl_setopt_array($ch, [
  64. CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
  65. CURLOPT_FOLLOWLOCATION => false,
  66. CURLOPT_SSL_VERIFYPEER => true,
  67. CURLOPT_SSL_VERIFYHOST => 2,
  68. CURLOPT_CONNECTTIMEOUT => 8,
  69. CURLOPT_TIMEOUT => $file !== null ? 180 : 15,
  70. CURLOPT_USERAGENT => $agent,
  71. CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
  72. CURLOPT_WRITEFUNCTION => $write,
  73. ]);
  74. $ok = curl_exec($ch) !== false;
  75. $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  76. } else {
  77. $context = stream_context_create([
  78. 'http' => ['method' => 'GET', 'timeout' => $file !== null ? 180 : 15, 'follow_location' => 0, 'ignore_errors' => true, 'header' => 'User-Agent: ' . $agent . "\r\nAccept: " . $accept],
  79. 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
  80. ]);
  81. $in = @fopen($url, 'rb', false, $context);
  82. if ($in !== false) {
  83. foreach ((array)(stream_get_meta_data($in)['wrapper_data'] ?? []) as $line) {
  84. if (is_string($line) && preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m)) $status = (int)$m[1];
  85. }
  86. $ok = true;
  87. while (!feof($in)) {
  88. $chunk = fread($in, 65536);
  89. if ($chunk === false) {
  90. $ok = false;
  91. break;
  92. }
  93. if ($chunk !== '' && $write(null, $chunk) === 0) break;
  94. }
  95. fclose($in);
  96. }
  97. }
  98. if ($out !== null) fclose($out);
  99. if ($over) return [null, 'update_err_size'];
  100. if (!$ok) return [null, 'update_err_network'];
  101. if ($status !== 200) return [null, 'update_err_server'];
  102. return [$body, null];
  103. }
  104. ​
  105. function sd_update_check(bool $force = false): array
  106. {
  107. $cached = sd_read_state('update');
  108. if (!$force && isset($cached['t']) && time() - (int)$cached['t'] < SD_UPDATE_TTL && ($cached['current'] ?? '') === SD_VERSION) return $cached;
  109. $result = ['t' => time(), 'current' => SD_VERSION];
  110. $missing = sd_update_missing();
  111. if ($missing !== null) {
  112. $result['error'] = $missing;
  113. } else {
  114. [$body, $error] = sd_update_fetch(SD_UPDATE_URL, null, 65536);
  115. $manifest = $body !== null ? json_decode($body, true) : null;
  116. if ($error !== null) {
  117. $result['error'] = $error;
  118. } elseif (!is_array($manifest) || !sd_update_version_ok($manifest['version'] ?? null) || !preg_match('/^[a-f0-9]{64}$/', (string)($manifest['sha256'] ?? ''))) {
  119. $result['error'] = 'update_err_manifest';
  120. } else {
  121. $changelog = [];
  122. foreach (array_slice((array)($manifest['changelog'] ?? []), 0, 40) as $line) {
  123. if (is_string($line) && trim($line) !== '') $changelog[] = mb_substr(trim($line), 0, 300);
  124. }
  125. $released = (string)($manifest['released_at'] ?? '');
  126. $result += [
  127. 'version' => (string)$manifest['version'],
  128. 'sha256' => (string)$manifest['sha256'],
  129. 'released' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $released) ? $released : '',
  130. 'changelog' => $changelog,
  131. ];
  132. }
  133. }
  134. sd_state('update', static function (array &$s) use ($result): void {
  135. $s = $result;
  136. });
  137. return $result;
  138. }
  139. ​
  140. function sd_update_available(): bool
  141. {
  142. $cached = sd_read_state('update');
  143. return ($cached['current'] ?? '') === SD_VERSION && sd_update_newer($cached['version'] ?? null);
  144. }
  145. ​
  146. function sd_update_path_ok(mixed $path): bool
  147. {
  148. if (!is_string($path) || strlen($path) > 200 || str_contains($path, '..')) return false;
  149. if (!preg_match('#^(?:[A-Za-z0-9_-][A-Za-z0-9._-]*/)*(?:[A-Za-z0-9_-][A-Za-z0-9._-]*|\.htaccess)$#', $path)) return false;
  150. $parts = explode('/', $path);
  151. if (count($parts) === 1) return !in_array(strtolower($path), SD_UPDATE_PROTECTED, true);
  152. return in_array($parts[0], SD_UPDATE_DIRS, true);
  153. }
  154. ​
  155. function sd_update_signed(ZipArchive $zip, string $version): ?array
  156. {
  157. $raw = $zip->getFromName(SD_UPDATE_PREFIX . 'release.sig');
  158. if (!is_string($raw) || strlen($raw) > 262144) return null;
  159. $wrapper = json_decode($raw, true);
  160. if (!is_array($wrapper)) return null;
  161. $payload = base64_decode((string)($wrapper['payload'] ?? ''), true);
  162. $signature = base64_decode((string)($wrapper['sig'] ?? ''), true);
  163. if ($payload === false || $signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) return null;
  164. if (!sodium_crypto_sign_verify_detached($signature, $payload, sodium_hex2bin(SD_UPDATE_KEY))) return null;
  165. $data = json_decode($payload, true);
  166. if (!is_array($data) || ($data['product'] ?? '') !== SD_UPDATE_PRODUCT || ($data['version'] ?? '') !== $version || !is_array($data['files'] ?? null)) return null;
  167. $files = [];
  168. foreach ($data['files'] as $path => $hash) {
  169. if (!sd_update_path_ok($path) || !is_string($hash) || !preg_match('/^[a-f0-9]{64}$/', $hash)) return null;
  170. $files[$path] = $hash;
  171. }
  172. if (!$files || count($files) > SD_UPDATE_MAX_FILES || !isset($files['index.php'], $files['app/core.php'])) return null;
  173. return $files;
  174. }
  175. ​
  176. function sd_update_zip_hash(ZipArchive $zip, string $name): ?string
  177. {
  178. $stat = $zip->statName($name);
  179. if ($stat === false || (int)$stat['size'] > SD_UPDATE_MAX_FILE) return null;
  180. $stream = $zip->getStream($name);
  181. if ($stream === false) return null;
  182. $ctx = hash_init('sha256');
  183. $read = 0;
  184. while (!feof($stream)) {
  185. $chunk = fread($stream, 65536);
  186. if ($chunk === false) break;
  187. $read += strlen($chunk);
  188. if ($read > SD_UPDATE_MAX_FILE) {
  189. fclose($stream);
  190. return null;
  191. }
  192. hash_update($ctx, $chunk);
  193. }
  194. fclose($stream);
  195. return hash_final($ctx);
  196. }
  197. ​
  198. function sd_update_parent_ok(string $path): bool
  199. {
  200. $dir = SD_ROOT;
  201. $parts = explode('/', $path);
  202. array_pop($parts);
  203. foreach ($parts as $part) {
  204. $dir .= '/' . $part;
  205. if (is_link($dir) || (file_exists($dir) && !is_dir($dir))) return false;
  206. }
  207. return true;
  208. }
  209. ​
  210. function sd_update_write(ZipArchive $zip, array $step, array &$dirs): bool
  211. {
  212. $dir = dirname($step['target']);
  213. $missing = [];
  214. for ($d = $dir; !is_dir($d) && strlen($d) > strlen(SD_ROOT); $d = dirname($d)) $missing[] = $d;
  215. foreach (array_reverse($missing) as $d) {
  216. if (!@mkdir($d, 0755)) return false;
  217. $dirs[] = $d;
  218. }
  219. $in = $zip->getStream($step['name']);
  220. if ($in === false) return false;
  221. $tmp = $dir . '/.sd-' . bin2hex(random_bytes(6)) . '.tmp';
  222. $out = @fopen($tmp, 'wb');
  223. if ($out === false) {
  224. fclose($in);
  225. return false;
  226. }
  227. $copied = stream_copy_to_stream($in, $out);
  228. fclose($in);
  229. $closed = fclose($out);
  230. if ($copied === false || !$closed || !hash_equals($step['hash'], (string)hash_file('sha256', $tmp))) {
  231. @unlink($tmp);
  232. return false;
  233. }
  234. @chmod($tmp, is_file($step['target']) ? ((@fileperms($step['target']) & 0777) ?: 0644) : 0644);
  235. if (!@rename($tmp, $step['target'])) {
  236. @chmod($tmp, 0600);
  237. @unlink($tmp);
  238. return false;
  239. }
  240. if (function_exists('opcache_invalidate') && str_ends_with($step['target'], '.php')) @opcache_invalidate($step['target'], true);
  241. return true;
  242. }
  243. ​
  244. function sd_update_forget(string $target): void
  245. {
  246. if (function_exists('opcache_invalidate') && str_ends_with($target, '.php')) @opcache_invalidate($target, true);
  247. }
  248. ​
  249. function sd_update_undo(array $done, string $backup, array $dirs): void
  250. {
  251. foreach (array_reverse($done) as $step) {
  252. if ($step['status'] === 'c') {
  253. sd_place($backup . '/files/' . $step['path'], $step['target'], (@fileperms($step['target']) & 0777) ?: null);
  254. } else {
  255. @unlink($step['target']);
  256. sd_update_forget($step['target']);
  257. }
  258. }
  259. foreach (array_reverse($dirs) as $d) @rmdir($d);
  260. }
  261. ​
  262. function sd_update_store(string $sub): string
  263. {
  264. return sd_data('updates/' . $sub);
  265. }
  266. ​
  267. function sd_update_lock(): mixed
  268. {
  269. $lock = @fopen(sd_data('state') . '/update.lock', 'c');
  270. if ($lock === false) return null;
  271. if (!flock($lock, LOCK_EX | LOCK_NB)) {
  272. fclose($lock);
  273. return null;
  274. }
  275. return $lock;
  276. }
  277. ​
  278. function sd_update_unlock(mixed $lock): void
  279. {
  280. flock($lock, LOCK_UN);
  281. fclose($lock);
  282. }
  283. ​
  284. function sd_update_install(): array
  285. {
  286. $missing = sd_update_missing();
  287. if ($missing !== null) return ['error' => $missing];
  288. $check = sd_update_check(true);
  289. if (isset($check['error'])) return ['error' => $check['error']];
  290. $version = (string)$check['version'];
  291. if (!sd_update_newer($version)) return ['error' => 'update_err_current'];
  292. $lock = sd_update_lock();
  293. if ($lock === null) return ['error' => 'update_err_busy'];
  294. $tmpDir = sd_update_store('tmp');
  295. foreach (glob($tmpDir . '/*.zip') ?: [] as $old) {
  296. if (time() - (int)@filemtime($old) > 3600) @unlink($old);
  297. }
  298. $tmp = $tmpDir . '/' . bin2hex(random_bytes(8)) . '.zip';
  299. $zip = new ZipArchive();
  300. $opened = false;
  301. try {
  302. [, $error] = sd_update_fetch(SD_UPDATE_URL . '?download=1', $tmp, SD_UPDATE_MAX_ZIP);
  303. if ($error !== null) return ['error' => $error];
  304. if (!hash_equals((string)$check['sha256'], (string)hash_file('sha256', $tmp))) return ['error' => 'update_err_checksum'];
  305. if ($zip->open($tmp) !== true) return ['error' => 'update_err_archive'];
  306. $opened = true;
  307. $files = sd_update_signed($zip, $version);
  308. if ($files === null) {
  309. sd_audit('update_rejected', ['to' => $version]);
  310. return ['error' => 'update_err_signature'];
  311. }
  312. $plan = [];
  313. foreach ($files as $path => $hash) {
  314. $name = SD_UPDATE_PREFIX . $path;
  315. if (sd_update_zip_hash($zip, $name) !== $hash) {
  316. sd_audit('update_rejected', ['to' => $version]);
  317. return ['error' => 'update_err_signature'];
  318. }
  319. $target = SD_ROOT . '/' . $path;
  320. if (!sd_update_parent_ok($path) || is_link($target) || is_dir($target)) return ['error' => 'update_err_write'];
  321. $exists = is_file($target);
  322. if ($exists && hash_equals($hash, (string)hash_file('sha256', $target))) continue;
  323. $plan[] = ['path' => $path, 'name' => $name, 'hash' => $hash, 'target' => $target, 'status' => $exists ? 'c' : 'n'];
  324. }
  325. if (!$plan) return ['error' => 'update_err_current'];
  326. ​
  327. $id = gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4));
  328. $backups = sd_update_store('backups');
  329. $backup = $backups . '/' . $id;
  330. if (!@mkdir($backup . '/files', 0700, true)) return ['error' => 'update_err_backup'];
  331. foreach ($plan as $step) {
  332. if ($step['status'] !== 'c') continue;
  333. $copy = $backup . '/files/' . $step['path'];
  334. if ((!is_dir(dirname($copy)) && !@mkdir(dirname($copy), 0700, true)) || !@copy($step['target'], $copy)) {
  335. sd_rrmdir($backup, $backups);
  336. return ['error' => 'update_err_backup'];
  337. }
  338. }
  339. $meta = ['id' => $id, 'from' => SD_VERSION, 'to' => $version, 't' => time(), 'files' => array_map(static fn(array $s): array => [$s['path'], $s['status']], $plan), 'dirs' => []];
  340. if (@file_put_contents($backup . '/meta.json', json_encode($meta, JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
  341. sd_rrmdir($backup, $backups);
  342. return ['error' => 'update_err_backup'];
  343. }
  344. ​
  345. $done = [];
  346. $dirs = [];
  347. foreach ($plan as $step) {
  348. if (!sd_update_write($zip, $step, $dirs)) {
  349. sd_update_undo($done, $backup, $dirs);
  350. sd_rrmdir($backup, $backups);
  351. sd_audit('update_failed', ['to' => $version, 'file' => $step['path']]);
  352. return ['error' => 'update_err_write'];
  353. }
  354. $done[] = $step;
  355. }
  356. $meta['dirs'] = array_map(static fn(string $d): string => ltrim(str_replace('\\', '/', substr($d, strlen(SD_ROOT))), '/'), $dirs);
  357. @file_put_contents($backup . '/meta.json', json_encode($meta, JSON_UNESCAPED_SLASHES), LOCK_EX);
  358. sd_update_prune();
  359. sd_state('update', static function (array &$s): void {
  360. $s = [];
  361. });
  362. sd_audit('update_ok', ['from' => SD_VERSION, 'to' => $version, 'files' => count($plan)]);
  363. sd_alert('alert_update_subject', 'alert_update_body', ['from' => SD_VERSION, 'to' => $version]);
  364. return ['ok' => true, 'version' => $version, 'files' => count($plan)];
  365. } finally {
  366. if ($opened) $zip->close();
  367. @unlink($tmp);
  368. sd_update_unlock($lock);
  369. }
  370. }
  371. ​
  372. function sd_update_meta(string $id): ?array
  373. {
  374. if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{8}$/', $id)) return null;
  375. $file = sd_update_store('backups') . '/' . $id . '/meta.json';
  376. $meta = is_file($file) ? json_decode((string)@file_get_contents($file), true) : null;
  377. if (!is_array($meta) || ($meta['id'] ?? '') !== $id || !sd_update_version_ok($meta['from'] ?? null) || !sd_update_version_ok($meta['to'] ?? null) || !is_array($meta['files'] ?? null)) return null;
  378. foreach ($meta['files'] as $entry) {
  379. if (!is_array($entry) || count($entry) !== 2 || !sd_update_path_ok($entry[0] ?? null) || !in_array($entry[1] ?? null, ['c', 'n'], true)) return null;
  380. }
  381. $meta['dirs'] = array_values(array_filter((array)($meta['dirs'] ?? []), 'sd_update_path_ok'));
  382. $meta['t'] = (int)($meta['t'] ?? 0);
  383. return $meta;
  384. }
  385. ​
  386. function sd_update_backups(): array
  387. {
  388. $list = [];
  389. foreach (glob(sd_update_store('backups') . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
  390. $meta = sd_update_meta(basename($dir));
  391. if ($meta !== null) $list[] = $meta;
  392. }
  393. usort($list, static fn(array $a, array $b): int => [$b['t'], $b['id']] <=> [$a['t'], $a['id']]);
  394. return $list;
  395. }
  396. ​
  397. function sd_update_restorable(array $meta, array $list): bool
  398. {
  399. return $list && $list[0]['id'] === $meta['id'] && $meta['to'] === SD_VERSION;
  400. }
  401. ​
  402. function sd_update_restore(string $id): array
  403. {
  404. $list = sd_update_backups();
  405. $meta = null;
  406. foreach ($list as $item) {
  407. if ($item['id'] === $id) $meta = $item;
  408. }
  409. if ($meta === null) return ['error' => 'update_err_missing'];
  410. if (!sd_update_restorable($meta, $list)) return ['error' => 'update_err_restore'];
  411. $lock = sd_update_lock();
  412. if ($lock === null) return ['error' => 'update_err_busy'];
  413. try {
  414. $backups = sd_update_store('backups');
  415. $dir = $backups . '/' . $id;
  416. foreach ($meta['files'] as [$path, $status]) {
  417. if ($status === 'c' && !is_file($dir . '/files/' . $path)) return ['error' => 'update_err_restore'];
  418. if (!sd_update_parent_ok($path) || is_link(SD_ROOT . '/' . $path) || is_dir(SD_ROOT . '/' . $path)) return ['error' => 'update_err_restore'];
  419. }
  420. $restored = 0;
  421. $failed = 0;
  422. foreach (array_reverse($meta['files']) as [$path, $status]) {
  423. $target = SD_ROOT . '/' . $path;
  424. if ($status === 'c') {
  425. if (sd_place($dir . '/files/' . $path, $target, is_file($target) ? ((@fileperms($target) & 0777) ?: null) : null)) $restored++;
  426. else $failed++;
  427. } elseif (is_file($target)) {
  428. if (@unlink($target)) {
  429. sd_update_forget($target);
  430. $restored++;
  431. } else {
  432. $failed++;
  433. }
  434. }
  435. }
  436. foreach (array_reverse($meta['dirs']) as $d) @rmdir(SD_ROOT . '/' . $d);
  437. if ($failed === 0) sd_rrmdir($dir, $backups);
  438. sd_state('update', static function (array &$s): void {
  439. $s = [];
  440. });
  441. sd_audit('update_restored', ['id' => $id, 'version' => $meta['from'], 'restored' => $restored, 'failed' => $failed]);
  442. return ['ok' => true, 'version' => $meta['from'], 'restored' => $restored, 'failed' => $failed];
  443. } finally {
  444. sd_update_unlock($lock);
  445. }
  446. }
  447. ​
  448. function sd_update_delete(string $id): bool
  449. {
  450. if (sd_update_meta($id) === null) return false;
  451. $backups = sd_update_store('backups');
  452. sd_rrmdir($backups . '/' . $id, $backups);
  453. $gone = !is_dir($backups . '/' . $id);
  454. if ($gone) sd_audit('update_deleted', ['id' => $id]);
  455. return $gone;
  456. }
  457. ​
  458. function sd_update_prune(): void
  459. {
  460. $backups = sd_update_store('backups');
  461. foreach (array_slice(sd_update_backups(), SD_UPDATE_KEEP) as $old) sd_rrmdir($backups . '/' . $old['id'], $backups);
  462. }
  463. ​
  464. function sd_route_update(): never
  465. {
  466. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  467. if (!sd_csrf_ok()) sd_redirect('update');
  468. @set_time_limit(300);
  469. ignore_user_abort(true);
  470. $id = (string)($_POST['id'] ?? '');
  471. switch ((string)($_POST['action'] ?? '')) {
  472. case 'check':
  473. $check = sd_update_check(true);
  474. if (isset($check['error'])) sd_flash('error', t($check['error']));
  475. break;
  476. case 'install':
  477. $result = sd_update_install();
  478. if (isset($result['ok'])) sd_flash('ok', t('update_msg_done', ['v' => $result['version'], 'n' => (string)$result['files']]));
  479. else sd_flash('error', t($result['error']));
  480. break;
  481. case 'restore':
  482. $result = sd_update_restore($id);
  483. if (!isset($result['ok'])) sd_flash('error', t($result['error']));
  484. elseif ($result['failed'] > 0) sd_flash('warn', t('update_msg_restore_partial', ['n' => (string)$result['restored'], 's' => (string)$result['failed']]));
  485. else sd_flash('ok', t('update_msg_restored', ['v' => $result['version'], 'n' => (string)$result['restored']]));
  486. break;
  487. case 'delete':
  488. $deleted = sd_update_delete($id);
  489. sd_flash($deleted ? 'ok' : 'error', t($deleted ? 'update_msg_deleted' : 'update_err_missing'));
  490. break;
  491. }
  492. sd_redirect('update');
  493. }
  494. sd_view_update();
  495. exit;
  496. }
  497. ​
  498. function sd_update_button(string $action, string $label, string $class, string $icon, string $id = '', string $confirm = ''): string
  499. {
  500. return '<form method="post" action="' . h(sd_url('update')) . '"' . ($confirm !== '' ? ' data-confirm="' . h($confirm) . '"' : '') . ' data-busy>' . sd_csrf_field()
  501. . '<input type="hidden" name="action" value="' . h($action) . '">' . ($id !== '' ? '<input type="hidden" name="id" value="' . h($id) . '">' : '')
  502. . '<button type="submit" class="orb-btn ' . h($class) . '">' . sd_icon($icon, 16) . h($label) . '</button></form>';
  503. }
  504. ​
  505. function sd_view_update(): void
  506. {
  507. $state = sd_update_check();
  508. sd_open(t('update_title'), 'app', 'update');
  509. $error = (string)($state['error'] ?? '');
  510. $newer = $error === '' && sd_update_newer($state['version'] ?? null);
  511. $checked = isset($state['t']) ? str_replace('{t}', sd_shield_time((int)$state['t']), h(t('update_checked'))) : '';
  512. ​
  513. echo '<section class="orb-card sd-update"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('download', 16) . ' SyncDrop</span><span class="orb-text-xs orb-text-muted">' . h(t('update_current', ['v' => SD_VERSION])) . '</span></div><div class="orb-card-body">';
  514. if ($newer) {
  515. $version = (string)$state['version'];
  516. $released = (string)($state['released'] ?? '');
  517. echo '<div class="sd-update-head"><span class="orb-pill orb-pill-info">' . h(t('update_new')) . '</span><strong class="sd-update-version">' . h(t('update_available', ['v' => $version])) . '</strong>'
  518. . ($released !== '' ? '<span class="orb-text-sm orb-text-muted">' . h(t('update_released', ['d' => $released])) . '</span>' : '') . '</div>';
  519. $changelog = (array)($state['changelog'] ?? []);
  520. if ($changelog) {
  521. echo '<p class="orb-label orb-mt-4">' . h(t('update_changes')) . '</p><ul class="sd-changelog">';
  522. foreach ($changelog as $line) echo '<li>' . h(ltrim((string)$line, "-*• \t")) . '</li>';
  523. echo '</ul>';
  524. }
  525. echo '<div class="orb-mt-6">' . sd_update_button('install', t('update_install', ['v' => $version]), 'orb-btn-primary orb-btn-lg', 'download', '', t('update_confirm', ['v' => $version])) . '</div>';
  526. } elseif ($error !== '') {
  527. echo sd_alert_box('warn', t($error));
  528. echo '<div class="orb-flex orb-items-center orb-gap-3 orb-flex-wrap orb-mt-4">' . sd_update_button('check', t('update_check'), 'orb-btn-secondary', 'sync') . ($checked !== '' ? '<span class="orb-text-xs orb-text-muted">' . $checked . '</span>' : '') . '</div>';
  529. } else {
  530. echo '<div class="sd-update-head"><span class="orb-pill orb-pill-success">' . h(t('update_latest')) . '</span></div>';
  531. echo '<div class="orb-flex orb-items-center orb-gap-3 orb-flex-wrap orb-mt-4">' . sd_update_button('check', t('update_check'), 'orb-btn-secondary', 'sync') . ($checked !== '' ? '<span class="orb-text-xs orb-text-muted">' . $checked . '</span>' : '') . '</div>';
  532. }
  533. echo '<p class="orb-form-hint orb-mt-4">' . sd_icon('shield', 12) . ' ' . h(t('update_safety')) . '</p></div></section>';
  534. ​
  535. $list = sd_update_backups();
  536. echo '<section class="orb-card orb-mt-4"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('history', 16) . ' ' . h(t('update_backups')) . '</span><span class="orb-badge">' . count($list) . '</span></div>';
  537. echo '<div class="orb-card-body sd-update-note orb-text-sm orb-text-muted">' . h(t('update_backups_hint', ['n' => (string)SD_UPDATE_KEEP])) . '</div>';
  538. if (!$list) {
  539. echo '<div class="orb-card-body orb-text-muted">' . h(t('update_backups_empty')) . '</div>';
  540. } else {
  541. echo '<div class="orb-table-wrap"><table class="orb-table orb-table-responsive"><thead><tr><th>' . h(t('col_date')) . '</th><th>' . h(t('col_versions')) . '</th><th>' . h(t('col_files')) . '</th><th></th></tr></thead><tbody>';
  542. foreach ($list as $item) {
  543. $actions = '';
  544. if (sd_update_restorable($item, $list)) $actions .= sd_update_button('restore', t('update_restore'), 'orb-btn-outline orb-btn-sm orb-tone-warning', 'undo', $item['id'], t('update_restore_confirm', ['v' => $item['from']]));
  545. $actions .= sd_update_button('delete', t('update_delete'), 'orb-btn-outline orb-btn-sm orb-tone-danger', 'trash', $item['id'], t('update_delete_confirm'));
  546. echo '<tr><td data-label="' . h(t('col_date')) . '">' . sd_shield_time($item['t']) . '</td>'
  547. . '<td data-label="' . h(t('col_versions')) . '" class="orb-mono">' . h($item['from']) . ' → ' . h($item['to']) . '</td>'
  548. . '<td data-label="' . h(t('col_files')) . '">' . count($item['files']) . '</td>'
  549. . '<td><div class="sd-update-actions">' . $actions . '</div></td></tr>';
  550. }
  551. echo '</tbody></table></div>';
  552. }
  553. echo '</section>';
  554. sd_close('app');
  555. }
  556. ​