v1.3.0
AtheraStudio SyncDrop
- <?php
- declare(strict_types=1);
-
- defined('SD_BOOT') || exit;
-
- const SD_UPDATE_URL = 'https://weborbiton.eu/updater/syncdrop/';
- const SD_UPDATE_KEY = 'a2b291a0aa90f059f5ff35b3096f534c25698d9481d965c358ac2639345f9769';
- const SD_UPDATE_PRODUCT = 'syncdrop';
- const SD_UPDATE_PREFIX = 'syncdrop/';
- const SD_UPDATE_TTL = 900;
- const SD_UPDATE_MAX_ZIP = 33554432;
- const SD_UPDATE_MAX_FILE = 16777216;
- const SD_UPDATE_MAX_FILES = 500;
- const SD_UPDATE_KEEP = 5;
- const SD_UPDATE_DIRS = ['app', 'assets', 'translate'];
- const SD_UPDATE_PROTECTED = ['config.php', '.syncdrop-id'];
-
- function sd_update_missing(): ?string
- {
- if (!class_exists('ZipArchive')) return 'update_need_zip';
- if (!function_exists('sodium_crypto_sign_verify_detached')) return 'update_need_sodium';
- if (!function_exists('curl_init') && !(filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL) && extension_loaded('openssl'))) return 'update_need_http';
- return null;
- }
-
- function sd_update_version_ok(mixed $version): bool
- {
- return is_string($version) && preg_match('/^\d{1,4}(\.\d{1,4}){1,3}$/', $version) === 1;
- }
-
- function sd_update_newer(mixed $version): bool
- {
- return sd_update_version_ok($version) && version_compare($version, SD_VERSION, '>');
- }
-
- function sd_update_fetch(string $url, ?string $file, int $max): array
- {
- if (!str_starts_with($url, 'https://')) return [null, 'update_err_network'];
- $out = null;
- if ($file !== null) {
- $out = @fopen($file, 'wb');
- if ($out === false) return [null, 'update_err_storage'];
- }
- $body = '';
- $size = 0;
- $over = false;
- $write = static function ($handle, string $chunk) use (&$body, &$size, &$over, $out, $max): int {
- $size += strlen($chunk);
- if ($size > $max) {
- $over = true;
- return 0;
- }
- if ($out !== null) fwrite($out, $chunk);
- else $body .= $chunk;
- return strlen($chunk);
- };
- $agent = 'AtheraStudio-SyncDrop/' . SD_VERSION;
- $accept = $file !== null ? 'application/zip' : 'application/json';
- $status = 0;
- $ok = false;
- if (function_exists('curl_init')) {
- $ch = curl_init($url);
- curl_setopt_array($ch, [
- CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
- CURLOPT_FOLLOWLOCATION => false,
- CURLOPT_SSL_VERIFYPEER => true,
- CURLOPT_SSL_VERIFYHOST => 2,
- CURLOPT_CONNECTTIMEOUT => 8,
- CURLOPT_TIMEOUT => $file !== null ? 180 : 15,
- CURLOPT_USERAGENT => $agent,
- CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
- CURLOPT_WRITEFUNCTION => $write,
- ]);
- $ok = curl_exec($ch) !== false;
- $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
- } else {
- $context = stream_context_create([
- 'http' => ['method' => 'GET', 'timeout' => $file !== null ? 180 : 15, 'follow_location' => 0, 'ignore_errors' => true, 'header' => 'User-Agent: ' . $agent . "\r\nAccept: " . $accept],
- 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
- ]);
- $in = @fopen($url, 'rb', false, $context);
- if ($in !== false) {
- foreach ((array)(stream_get_meta_data($in)['wrapper_data'] ?? []) as $line) {
- if (is_string($line) && preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $m)) $status = (int)$m[1];
- }
- $ok = true;
- while (!feof($in)) {
- $chunk = fread($in, 65536);
- if ($chunk === false) {
- $ok = false;
- break;
- }
- if ($chunk !== '' && $write(null, $chunk) === 0) break;
- }
- fclose($in);
- }
- }
- if ($out !== null) fclose($out);
- if ($over) return [null, 'update_err_size'];
- if (!$ok) return [null, 'update_err_network'];
- if ($status !== 200) return [null, 'update_err_server'];
- return [$body, null];
- }
-
- function sd_update_check(bool $force = false): array
- {
- $cached = sd_read_state('update');
- if (!$force && isset($cached['t']) && time() - (int)$cached['t'] < SD_UPDATE_TTL && ($cached['current'] ?? '') === SD_VERSION) return $cached;
- $result = ['t' => time(), 'current' => SD_VERSION];
- $missing = sd_update_missing();
- if ($missing !== null) {
- $result['error'] = $missing;
- } else {
- [$body, $error] = sd_update_fetch(SD_UPDATE_URL, null, 65536);
- $manifest = $body !== null ? json_decode($body, true) : null;
- if ($error !== null) {
- $result['error'] = $error;
- } elseif (!is_array($manifest) || !sd_update_version_ok($manifest['version'] ?? null) || !preg_match('/^[a-f0-9]{64}$/', (string)($manifest['sha256'] ?? ''))) {
- $result['error'] = 'update_err_manifest';
- } else {
- $changelog = [];
- foreach (array_slice((array)($manifest['changelog'] ?? []), 0, 40) as $line) {
- if (is_string($line) && trim($line) !== '') $changelog[] = mb_substr(trim($line), 0, 300);
- }
- $released = (string)($manifest['released_at'] ?? '');
- $result += [
- 'version' => (string)$manifest['version'],
- 'sha256' => (string)$manifest['sha256'],
- 'released' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $released) ? $released : '',
- 'changelog' => $changelog,
- ];
- }
- }
- sd_state('update', static function (array &$s) use ($result): void {
- $s = $result;
- });
- return $result;
- }
-
- function sd_update_available(): bool
- {
- $cached = sd_read_state('update');
- return ($cached['current'] ?? '') === SD_VERSION && sd_update_newer($cached['version'] ?? null);
- }
-
- function sd_update_path_ok(mixed $path): bool
- {
- if (!is_string($path) || strlen($path) > 200 || str_contains($path, '..')) return false;
- if (!preg_match('#^(?:[A-Za-z0-9_-][A-Za-z0-9._-]*/)*(?:[A-Za-z0-9_-][A-Za-z0-9._-]*|\.htaccess)$#', $path)) return false;
- $parts = explode('/', $path);
- if (count($parts) === 1) return !in_array(strtolower($path), SD_UPDATE_PROTECTED, true);
- return in_array($parts[0], SD_UPDATE_DIRS, true);
- }
-
- function sd_update_signed(ZipArchive $zip, string $version): ?array
- {
- $raw = $zip->getFromName(SD_UPDATE_PREFIX . 'release.sig');
- if (!is_string($raw) || strlen($raw) > 262144) return null;
- $wrapper = json_decode($raw, true);
- if (!is_array($wrapper)) return null;
- $payload = base64_decode((string)($wrapper['payload'] ?? ''), true);
- $signature = base64_decode((string)($wrapper['sig'] ?? ''), true);
- if ($payload === false || $signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) return null;
- if (!sodium_crypto_sign_verify_detached($signature, $payload, sodium_hex2bin(SD_UPDATE_KEY))) return null;
- $data = json_decode($payload, true);
- if (!is_array($data) || ($data['product'] ?? '') !== SD_UPDATE_PRODUCT || ($data['version'] ?? '') !== $version || !is_array($data['files'] ?? null)) return null;
- $files = [];
- foreach ($data['files'] as $path => $hash) {
- if (!sd_update_path_ok($path) || !is_string($hash) || !preg_match('/^[a-f0-9]{64}$/', $hash)) return null;
- $files[$path] = $hash;
- }
- if (!$files || count($files) > SD_UPDATE_MAX_FILES || !isset($files['index.php'], $files['app/core.php'])) return null;
- return $files;
- }
-
- function sd_update_zip_hash(ZipArchive $zip, string $name): ?string
- {
- $stat = $zip->statName($name);
- if ($stat === false || (int)$stat['size'] > SD_UPDATE_MAX_FILE) return null;
- $stream = $zip->getStream($name);
- if ($stream === false) return null;
- $ctx = hash_init('sha256');
- $read = 0;
- while (!feof($stream)) {
- $chunk = fread($stream, 65536);
- if ($chunk === false) break;
- $read += strlen($chunk);
- if ($read > SD_UPDATE_MAX_FILE) {
- fclose($stream);
- return null;
- }
- hash_update($ctx, $chunk);
- }
- fclose($stream);
- return hash_final($ctx);
- }
-
- function sd_update_parent_ok(string $path): bool
- {
- $dir = SD_ROOT;
- $parts = explode('/', $path);
- array_pop($parts);
- foreach ($parts as $part) {
- $dir .= '/' . $part;
- if (is_link($dir) || (file_exists($dir) && !is_dir($dir))) return false;
- }
- return true;
- }
-
- function sd_update_write(ZipArchive $zip, array $step, array &$dirs): bool
- {
- $dir = dirname($step['target']);
- $missing = [];
- for ($d = $dir; !is_dir($d) && strlen($d) > strlen(SD_ROOT); $d = dirname($d)) $missing[] = $d;
- foreach (array_reverse($missing) as $d) {
- if (!@mkdir($d, 0755)) return false;
- $dirs[] = $d;
- }
- $in = $zip->getStream($step['name']);
- if ($in === false) return false;
- $tmp = $dir . '/.sd-' . bin2hex(random_bytes(6)) . '.tmp';
- $out = @fopen($tmp, 'wb');
- if ($out === false) {
- fclose($in);
- return false;
- }
- $copied = stream_copy_to_stream($in, $out);
- fclose($in);
- $closed = fclose($out);
- if ($copied === false || !$closed || !hash_equals($step['hash'], (string)hash_file('sha256', $tmp))) {
- @unlink($tmp);
- return false;
- }
- @chmod($tmp, is_file($step['target']) ? ((@fileperms($step['target']) & 0777) ?: 0644) : 0644);
- if (!@rename($tmp, $step['target'])) {
- @chmod($tmp, 0600);
- @unlink($tmp);
- return false;
- }
- if (function_exists('opcache_invalidate') && str_ends_with($step['target'], '.php')) @opcache_invalidate($step['target'], true);
- return true;
- }
-
- function sd_update_forget(string $target): void
- {
- if (function_exists('opcache_invalidate') && str_ends_with($target, '.php')) @opcache_invalidate($target, true);
- }
-
- function sd_update_undo(array $done, string $backup, array $dirs): void
- {
- foreach (array_reverse($done) as $step) {
- if ($step['status'] === 'c') {
- sd_place($backup . '/files/' . $step['path'], $step['target'], (@fileperms($step['target']) & 0777) ?: null);
- } else {
- @unlink($step['target']);
- sd_update_forget($step['target']);
- }
- }
- foreach (array_reverse($dirs) as $d) @rmdir($d);
- }
-
- function sd_update_store(string $sub): string
- {
- return sd_data('updates/' . $sub);
- }
-
- function sd_update_lock(): mixed
- {
- $lock = @fopen(sd_data('state') . '/update.lock', 'c');
- if ($lock === false) return null;
- if (!flock($lock, LOCK_EX | LOCK_NB)) {
- fclose($lock);
- return null;
- }
- return $lock;
- }
-
- function sd_update_unlock(mixed $lock): void
- {
- flock($lock, LOCK_UN);
- fclose($lock);
- }
-
- function sd_update_install(): array
- {
- $missing = sd_update_missing();
- if ($missing !== null) return ['error' => $missing];
- $check = sd_update_check(true);
- if (isset($check['error'])) return ['error' => $check['error']];
- $version = (string)$check['version'];
- if (!sd_update_newer($version)) return ['error' => 'update_err_current'];
- $lock = sd_update_lock();
- if ($lock === null) return ['error' => 'update_err_busy'];
- $tmpDir = sd_update_store('tmp');
- foreach (glob($tmpDir . '/*.zip') ?: [] as $old) {
- if (time() - (int)@filemtime($old) > 3600) @unlink($old);
- }
- $tmp = $tmpDir . '/' . bin2hex(random_bytes(8)) . '.zip';
- $zip = new ZipArchive();
- $opened = false;
- try {
- [, $error] = sd_update_fetch(SD_UPDATE_URL . '?download=1', $tmp, SD_UPDATE_MAX_ZIP);
- if ($error !== null) return ['error' => $error];
- if (!hash_equals((string)$check['sha256'], (string)hash_file('sha256', $tmp))) return ['error' => 'update_err_checksum'];
- if ($zip->open($tmp) !== true) return ['error' => 'update_err_archive'];
- $opened = true;
- $files = sd_update_signed($zip, $version);
- if ($files === null) {
- sd_audit('update_rejected', ['to' => $version]);
- return ['error' => 'update_err_signature'];
- }
- $plan = [];
- foreach ($files as $path => $hash) {
- $name = SD_UPDATE_PREFIX . $path;
- if (sd_update_zip_hash($zip, $name) !== $hash) {
- sd_audit('update_rejected', ['to' => $version]);
- return ['error' => 'update_err_signature'];
- }
- $target = SD_ROOT . '/' . $path;
- if (!sd_update_parent_ok($path) || is_link($target) || is_dir($target)) return ['error' => 'update_err_write'];
- $exists = is_file($target);
- if ($exists && hash_equals($hash, (string)hash_file('sha256', $target))) continue;
- $plan[] = ['path' => $path, 'name' => $name, 'hash' => $hash, 'target' => $target, 'status' => $exists ? 'c' : 'n'];
- }
- if (!$plan) return ['error' => 'update_err_current'];
-
- $id = gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4));
- $backups = sd_update_store('backups');
- $backup = $backups . '/' . $id;
- if (!@mkdir($backup . '/files', 0700, true)) return ['error' => 'update_err_backup'];
- foreach ($plan as $step) {
- if ($step['status'] !== 'c') continue;
- $copy = $backup . '/files/' . $step['path'];
- if ((!is_dir(dirname($copy)) && !@mkdir(dirname($copy), 0700, true)) || !@copy($step['target'], $copy)) {
- sd_rrmdir($backup, $backups);
- return ['error' => 'update_err_backup'];
- }
- }
- $meta = ['id' => $id, 'from' => SD_VERSION, 'to' => $version, 't' => time(), 'files' => array_map(static fn(array $s): array => [$s['path'], $s['status']], $plan), 'dirs' => []];
- if (@file_put_contents($backup . '/meta.json', json_encode($meta, JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
- sd_rrmdir($backup, $backups);
- return ['error' => 'update_err_backup'];
- }
-
- $done = [];
- $dirs = [];
- foreach ($plan as $step) {
- if (!sd_update_write($zip, $step, $dirs)) {
- sd_update_undo($done, $backup, $dirs);
- sd_rrmdir($backup, $backups);
- sd_audit('update_failed', ['to' => $version, 'file' => $step['path']]);
- return ['error' => 'update_err_write'];
- }
- $done[] = $step;
- }
- $meta['dirs'] = array_map(static fn(string $d): string => ltrim(str_replace('\\', '/', substr($d, strlen(SD_ROOT))), '/'), $dirs);
- @file_put_contents($backup . '/meta.json', json_encode($meta, JSON_UNESCAPED_SLASHES), LOCK_EX);
- sd_update_prune();
- sd_state('update', static function (array &$s): void {
- $s = [];
- });
- sd_audit('update_ok', ['from' => SD_VERSION, 'to' => $version, 'files' => count($plan)]);
- sd_alert('alert_update_subject', 'alert_update_body', ['from' => SD_VERSION, 'to' => $version]);
- return ['ok' => true, 'version' => $version, 'files' => count($plan)];
- } finally {
- if ($opened) $zip->close();
- @unlink($tmp);
- sd_update_unlock($lock);
- }
- }
-
- function sd_update_meta(string $id): ?array
- {
- if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{8}$/', $id)) return null;
- $file = sd_update_store('backups') . '/' . $id . '/meta.json';
- $meta = is_file($file) ? json_decode((string)@file_get_contents($file), true) : null;
- 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;
- foreach ($meta['files'] as $entry) {
- if (!is_array($entry) || count($entry) !== 2 || !sd_update_path_ok($entry[0] ?? null) || !in_array($entry[1] ?? null, ['c', 'n'], true)) return null;
- }
- $meta['dirs'] = array_values(array_filter((array)($meta['dirs'] ?? []), 'sd_update_path_ok'));
- $meta['t'] = (int)($meta['t'] ?? 0);
- return $meta;
- }
-
- function sd_update_backups(): array
- {
- $list = [];
- foreach (glob(sd_update_store('backups') . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
- $meta = sd_update_meta(basename($dir));
- if ($meta !== null) $list[] = $meta;
- }
- usort($list, static fn(array $a, array $b): int => [$b['t'], $b['id']] <=> [$a['t'], $a['id']]);
- return $list;
- }
-
- function sd_update_restorable(array $meta, array $list): bool
- {
- return $list && $list[0]['id'] === $meta['id'] && $meta['to'] === SD_VERSION;
- }
-
- function sd_update_restore(string $id): array
- {
- $list = sd_update_backups();
- $meta = null;
- foreach ($list as $item) {
- if ($item['id'] === $id) $meta = $item;
- }
- if ($meta === null) return ['error' => 'update_err_missing'];
- if (!sd_update_restorable($meta, $list)) return ['error' => 'update_err_restore'];
- $lock = sd_update_lock();
- if ($lock === null) return ['error' => 'update_err_busy'];
- try {
- $backups = sd_update_store('backups');
- $dir = $backups . '/' . $id;
- foreach ($meta['files'] as [$path, $status]) {
- if ($status === 'c' && !is_file($dir . '/files/' . $path)) return ['error' => 'update_err_restore'];
- if (!sd_update_parent_ok($path) || is_link(SD_ROOT . '/' . $path) || is_dir(SD_ROOT . '/' . $path)) return ['error' => 'update_err_restore'];
- }
- $restored = 0;
- $failed = 0;
- foreach (array_reverse($meta['files']) as [$path, $status]) {
- $target = SD_ROOT . '/' . $path;
- if ($status === 'c') {
- if (sd_place($dir . '/files/' . $path, $target, is_file($target) ? ((@fileperms($target) & 0777) ?: null) : null)) $restored++;
- else $failed++;
- } elseif (is_file($target)) {
- if (@unlink($target)) {
- sd_update_forget($target);
- $restored++;
- } else {
- $failed++;
- }
- }
- }
- foreach (array_reverse($meta['dirs']) as $d) @rmdir(SD_ROOT . '/' . $d);
- if ($failed === 0) sd_rrmdir($dir, $backups);
- sd_state('update', static function (array &$s): void {
- $s = [];
- });
- sd_audit('update_restored', ['id' => $id, 'version' => $meta['from'], 'restored' => $restored, 'failed' => $failed]);
- return ['ok' => true, 'version' => $meta['from'], 'restored' => $restored, 'failed' => $failed];
- } finally {
- sd_update_unlock($lock);
- }
- }
-
- function sd_update_delete(string $id): bool
- {
- if (sd_update_meta($id) === null) return false;
- $backups = sd_update_store('backups');
- sd_rrmdir($backups . '/' . $id, $backups);
- $gone = !is_dir($backups . '/' . $id);
- if ($gone) sd_audit('update_deleted', ['id' => $id]);
- return $gone;
- }
-
- function sd_update_prune(): void
- {
- $backups = sd_update_store('backups');
- foreach (array_slice(sd_update_backups(), SD_UPDATE_KEEP) as $old) sd_rrmdir($backups . '/' . $old['id'], $backups);
- }
-
- function sd_route_update(): never
- {
- if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
- if (!sd_csrf_ok()) sd_redirect('update');
- @set_time_limit(300);
- ignore_user_abort(true);
- $id = (string)($_POST['id'] ?? '');
- switch ((string)($_POST['action'] ?? '')) {
- case 'check':
- $check = sd_update_check(true);
- if (isset($check['error'])) sd_flash('error', t($check['error']));
- break;
- case 'install':
- $result = sd_update_install();
- if (isset($result['ok'])) sd_flash('ok', t('update_msg_done', ['v' => $result['version'], 'n' => (string)$result['files']]));
- else sd_flash('error', t($result['error']));
- break;
- case 'restore':
- $result = sd_update_restore($id);
- if (!isset($result['ok'])) sd_flash('error', t($result['error']));
- elseif ($result['failed'] > 0) sd_flash('warn', t('update_msg_restore_partial', ['n' => (string)$result['restored'], 's' => (string)$result['failed']]));
- else sd_flash('ok', t('update_msg_restored', ['v' => $result['version'], 'n' => (string)$result['restored']]));
- break;
- case 'delete':
- $deleted = sd_update_delete($id);
- sd_flash($deleted ? 'ok' : 'error', t($deleted ? 'update_msg_deleted' : 'update_err_missing'));
- break;
- }
- sd_redirect('update');
- }
- sd_view_update();
- exit;
- }
-
- function sd_update_button(string $action, string $label, string $class, string $icon, string $id = '', string $confirm = ''): string
- {
- return '<form method="post" action="' . h(sd_url('update')) . '"' . ($confirm !== '' ? ' data-confirm="' . h($confirm) . '"' : '') . ' data-busy>' . sd_csrf_field()
- . '<input type="hidden" name="action" value="' . h($action) . '">' . ($id !== '' ? '<input type="hidden" name="id" value="' . h($id) . '">' : '')
- . '<button type="submit" class="orb-btn ' . h($class) . '">' . sd_icon($icon, 16) . h($label) . '</button></form>';
- }
-
- function sd_view_update(): void
- {
- $state = sd_update_check();
- sd_open(t('update_title'), 'app', 'update');
- $error = (string)($state['error'] ?? '');
- $newer = $error === '' && sd_update_newer($state['version'] ?? null);
- $checked = isset($state['t']) ? str_replace('{t}', sd_shield_time((int)$state['t']), h(t('update_checked'))) : '';
-
- 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">';
- if ($newer) {
- $version = (string)$state['version'];
- $released = (string)($state['released'] ?? '');
- 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>'
- . ($released !== '' ? '<span class="orb-text-sm orb-text-muted">' . h(t('update_released', ['d' => $released])) . '</span>' : '') . '</div>';
- $changelog = (array)($state['changelog'] ?? []);
- if ($changelog) {
- echo '<p class="orb-label orb-mt-4">' . h(t('update_changes')) . '</p><ul class="sd-changelog">';
- foreach ($changelog as $line) echo '<li>' . h(ltrim((string)$line, "-*• \t")) . '</li>';
- echo '</ul>';
- }
- 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>';
- } elseif ($error !== '') {
- echo sd_alert_box('warn', t($error));
- 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>';
- } else {
- echo '<div class="sd-update-head"><span class="orb-pill orb-pill-success">' . h(t('update_latest')) . '</span></div>';
- 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>';
- }
- echo '<p class="orb-form-hint orb-mt-4">' . sd_icon('shield', 12) . ' ' . h(t('update_safety')) . '</p></div></section>';
-
- $list = sd_update_backups();
- 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>';
- 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>';
- if (!$list) {
- echo '<div class="orb-card-body orb-text-muted">' . h(t('update_backups_empty')) . '</div>';
- } else {
- 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>';
- foreach ($list as $item) {
- $actions = '';
- 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']]));
- $actions .= sd_update_button('delete', t('update_delete'), 'orb-btn-outline orb-btn-sm orb-tone-danger', 'trash', $item['id'], t('update_delete_confirm'));
- echo '<tr><td data-label="' . h(t('col_date')) . '">' . sd_shield_time($item['t']) . '</td>'
- . '<td data-label="' . h(t('col_versions')) . '" class="orb-mono">' . h($item['from']) . ' → ' . h($item['to']) . '</td>'
- . '<td data-label="' . h(t('col_files')) . '">' . count($item['files']) . '</td>'
- . '<td><div class="sd-update-actions">' . $actions . '</div></td></tr>';
- }
- echo '</tbody></table></div>';
- }
- echo '</section>';
- sd_close('app');
- }
-