v1.3.0
AtheraStudio SyncDrop
- <?php
- declare(strict_types=1);
-
- defined('SD_BOOT') || exit;
-
- const SD_ICONS = [
- 'sync' => '<path d="M21 12a9 9 0 0 1-15.4 6.4L3 16"/><path d="M3 12a9 9 0 0 1 15.4-6.4L21 8"/><path d="M21 3v5h-5"/><path d="M3 21v-5h5"/>',
- 'history' => '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>',
- 'settings' => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
- 'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
- 'shield' => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>',
- 'folder' => '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
- 'upload' => '<path d="M12 15V3"/><path d="m7 8 5-5 5 5"/><path d="M20 15v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4"/>',
- 'check' => '<path d="M20 6 9 17l-5-5"/>',
- 'alert' => '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
- 'info' => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
- 'key' => '<circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
- 'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
- 'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
- 'moon' => '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>',
- 'copy' => '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
- 'download' => '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M20 15v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4"/>',
- 'server' => '<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>',
- 'trash' => '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
- 'plus' => '<path d="M12 5v14M5 12h14"/>',
- 'undo' => '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-3"/>',
- 'mail' => '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/>',
- 'globe' => '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/>',
- 'code' => '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
- 'close' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
- ];
-
- const SD_LOGO = 'M1.35 0L18.45 16.76L18.28 38.25L1.43 54.84ZM41.53 23L53.24 23L53.33 25.95L34.71 44.82L29.65 39.93L27.55 37.49ZM57.12 27.38L69.25 27.38L69.08 30.24L44.99 54.59L39.6 49.28L37.83 47.18ZM72.03 32.52L83.91 32.6L83.99 35.3L61.25 58.38L49.28 58.47L49.37 55.52ZM22.24 40.69L40.61 59.48L3.96 59.48ZM83.4 40.78L98.65 40.94L84.92 55.77L84.92 58.47L66.39 58.47ZM32.43 63.86L35.89 63.86L50.72 78.77L65.63 63.94L84.92 64.2L46.59 100L11.71 64.11Z';
-
- function sd_logo(int $size): string
- {
- return '<svg class="sd-logo" width="' . $size . '" height="' . $size . '" viewBox="0 0 100 100" aria-hidden="true"><path d="' . SD_LOGO . '"/></svg>';
- }
-
- function sd_icon(string $name, int $size = 18): string
- {
- return '<svg width="' . $size . '" height="' . $size . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . (SD_ICONS[$name] ?? '') . '</svg>';
- }
-
- function sd_asset(string $file): string
- {
- $path = SD_ROOT . '/assets/' . $file;
- return sd_url_base() . 'assets/' . $file . '?v=' . (is_file($path) ? substr(md5((string)filemtime($path) . SD_VERSION), 0, 8) : SD_VERSION);
- }
-
- function sd_js_strings(): array
- {
- $keys = ['js_hashing', 'js_comparing', 'js_uploading', 'js_verifying', 'js_done', 'js_failed', 'js_rolled_back', 'js_nothing', 'js_pick_first', 'js_files', 'js_status_n', 'js_status_c', 'js_status_s', 'js_status_x', 'js_status_e', 'js_status_b', 'js_send', 'js_confirm_rollback', 'js_copied', 'js_working', 'js_err_auth', 'js_err_busy', 'js_err_disk', 'js_err_verify', 'js_err_generic', 'js_summary', 'js_too_many', 'js_more', 'js_solving', 'js_view', 'js_view_loading', 'js_view_binary', 'js_view_large', 'js_view_server_binary', 'js_view_server_large', 'js_view_server_missing', 'js_view_same', 'js_view_too_many', 'js_view_hidden', 'js_view_lines'];
- $out = [];
- foreach ($keys as $k) $out[$k] = t($k);
- return $out;
- }
-
- function sd_open(string $title, string $mode = 'auth', string $active = ''): void
- {
- $lang = sd_lang();
- echo '<!doctype html><html lang="' . h($lang) . '" class="orb-reset orb-app orb-depth" data-orb-accent="fuchsia"><head><meta charset="utf-8">'
- . '<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow"><meta name="referrer" content="no-referrer">'
- . '<meta name="theme-color" content="#f8fafc"><title>' . h($title) . ' · ' . h(SD_NAME) . '</title>'
- . '<link rel="icon" href="' . h(sd_asset('icon.svg')) . '" type="image/svg+xml"><link rel="icon" href="' . h(sd_asset('icon-32.png')) . '" type="image/png" sizes="32x32"><link rel="apple-touch-icon" href="' . h(sd_asset('icon-180.png')) . '">'
- . '<link rel="preload" href="' . h(sd_url_base() . 'assets/fonts/SUSE.ttf') . '" as="font" type="font/ttf" crossorigin><link rel="preload" href="' . h(sd_url_base() . 'assets/fonts/Tektur.ttf') . '" as="font" type="font/ttf" crossorigin>'
- . '<link rel="stylesheet" href="' . h(sd_asset('orbitarium.css')) . '"><link rel="stylesheet" href="' . h(sd_asset('app.css')) . '">'
- . '<script src="' . h(sd_asset('theme.js')) . '"></script>'
- . '<script src="' . h(sd_asset('orbitarium.js')) . '" defer></script><script src="' . h(sd_asset('app.js')) . '" defer></script></head>'
- . '<body class="sd-' . h($mode) . '" data-base="' . h(sd_url_base()) . '" data-csrf="' . h(sd_csrf()) . '" data-i18n="' . h(json_encode(sd_js_strings(), JSON_UNESCAPED_UNICODE)) . '">'
- . '<noscript><div class="sd-noscript">' . h(t('noscript')) . '</div></noscript>';
- if ($mode === 'app') {
- $nav = [['sync', 'sync', t('nav_sync')], ['history', 'history', t('nav_history')], ['shield', 'shield', t('nav_shield')], ['update', 'download', t('nav_update')], ['settings', 'settings', t('nav_settings')]];
- $fresh = sd_update_available();
- echo '<div class="orb-shell"><aside class="orb-sidebar"><div class="orb-sidebar-brand"><span class="orb-brand-mark">' . sd_logo(19) . '</span><span class="sd-brand-text"><small>AtheraStudio</small>SyncDrop</span></div><nav class="orb-nav" aria-label="' . h(t('nav_label')) . '"><span class="orb-nav-label">' . h(t('nav_label')) . '</span>';
- foreach ($nav as [$page, $icon, $label]) {
- echo '<a class="orb-nav-item' . ($active === $page ? ' is-active' : '') . '" href="' . h(sd_url($page)) . '"' . ($active === $page ? ' aria-current="page"' : '') . '>' . sd_icon($icon) . '<span>' . h($label) . '</span>' . ($page === 'update' && $fresh ? '<span class="sd-nav-dot" title="' . h(t('update_nav_new')) . '"><span class="sd-sr">' . h(t('update_nav_new')) . '</span></span>' : '') . '</a>';
- }
- echo '<form method="post" action="' . h(sd_url('logout')) . '" class="sd-nav-form"><input type="hidden" name="_csrf" value="' . h(sd_csrf()) . '"><button type="submit" class="orb-nav-item sd-nav-button">' . sd_icon('logout') . '<span>' . h(t('nav_logout')) . '</span></button></form>'
- . '</nav><div class="sd-sidebar-foot orb-text-xs orb-text-subtle">v' . h(SD_VERSION) . '</div></aside><main class="orb-main"><header class="sd-topbar"><h1 class="orb-h2">' . h($title) . '</h1><div class="sd-topbar-actions">' . sd_theme_button() . '</div></header>';
- } else {
- echo '<main class="sd-auth-wrap"><div class="sd-auth-top">' . sd_theme_button() . sd_lang_switch() . '</div><div class="sd-auth-brand"><span class="orb-brand-mark sd-brand-lg">' . sd_logo(30) . '</span><div><div class="sd-brand-small">AtheraStudio</div><div class="sd-brand-big">SyncDrop</div></div></div>';
- }
- $flash = sd_flash();
- if ($flash) echo sd_alert_box($flash['type'], $flash['msg']);
- }
-
- function sd_close(string $mode = 'auth'): void
- {
- if ($mode === 'app') echo '</main></div>';
- else echo '<p class="sd-auth-foot orb-text-xs orb-text-subtle">' . h(SD_NAME) . ' ' . h(SD_VERSION) . '</p></main>';
- echo '</body></html>';
- }
-
- function sd_theme_button(): string
- {
- return '<button type="button" class="orb-btn orb-btn-ghost orb-btn-icon orb-btn-sm" data-orb-theme-toggle aria-label="' . h(t('theme')) . '"><span class="orb-icon-moon">' . sd_icon('moon') . '</span><span class="orb-icon-sun">' . sd_icon('sun') . '</span></button>';
- }
-
- function sd_lang_switch(): string
- {
- $out = '<form method="post" action="' . h(sd_url('lang')) . '" class="sd-lang"><input type="hidden" name="_csrf" value="' . h(sd_csrf()) . '">';
- foreach (SD_LANGS as $code => $name) {
- $out .= '<button type="submit" name="lang" value="' . h($code) . '" class="orb-btn orb-btn-ghost orb-btn-xs' . (sd_lang() === $code ? ' is-current' : '') . '" title="' . h($name) . '">' . strtoupper($code) . '</button>';
- }
- return $out . '</form>';
- }
-
- function sd_alert_box(string $type, string $message, string $title = ''): string
- {
- $tone = ['ok' => 'success', 'error' => 'danger', 'warn' => 'warning'][$type] ?? 'info';
- $icon = ['success' => 'check', 'danger' => 'alert', 'warning' => 'alert'][$tone] ?? 'info';
- return '<div class="orb-alert orb-alert-' . $tone . ' sd-alert" role="' . ($tone === 'danger' ? 'alert' : 'status') . '"><span class="orb-alert-icon">' . sd_icon($icon, 20) . '</span><div class="orb-alert-body">'
- . ($title !== '' ? '<div class="orb-alert-title">' . h($title) . '</div>' : '') . '<div class="orb-alert-text">' . h($message) . '</div></div></div>';
- }
-
- function sd_csrf_field(): string
- {
- return '<input type="hidden" name="_csrf" value="' . h(sd_csrf()) . '">';
- }
-
- function sd_pow_fields(string $form): string
- {
- return '<input type="hidden" name="_pow" value="' . h(sd_pow_issue($form)) . '"><input type="hidden" name="_nonce" value="">'
- . '<div class="sd-hp" aria-hidden="true"><label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>';
- }
-
- function sd_field(string $name, string $label, string $type = 'text', array $attrs = [], string $hint = ''): string
- {
- $id = (string)($attrs['id'] ?? 'f-' . $name);
- unset($attrs['id']);
- $extra = '';
- foreach ($attrs as $k => $v) $extra .= $v === true ? ' ' . h($k) : ' ' . h($k) . '="' . h((string)$v) . '"';
- return '<div class="orb-form-group"><label class="orb-label" for="' . $id . '">' . h($label) . '</label><input class="orb-input" id="' . $id . '" name="' . h($name) . '" type="' . h($type) . '"' . $extra . '>'
- . ($hint !== '' ? '<p class="orb-form-hint">' . h($hint) . '</p>' : '') . '</div>';
- }
-
- function sd_auth_card(string $title, string $lead, string $body): string
- {
- return '<section class="orb-card sd-auth-card"><div class="orb-card-body"><h1 class="orb-h2">' . h($title) . '</h1>'
- . ($lead !== '' ? '<p class="orb-lead orb-mt-2 orb-mb-6">' . h($lead) . '</p>' : '') . $body . '</div></section>';
- }
-
- function sd_view_fatal(string $title, string $message): never
- {
- http_response_code(503);
- sd_open($title);
- echo sd_auth_card($title, '', sd_alert_box('error', $message));
- sd_close();
- exit;
- }
-
- function sd_view_setup(array $errors, array $old): void
- {
- sd_open(t('setup_title'));
- $errs = '';
- foreach ($errors as $e) $errs .= sd_alert_box('error', t($e));
- $langOptions = '';
- foreach (SD_LANGS as $code => $name) $langOptions .= '<option value="' . h($code) . '"' . (($old['lang'] ?? sd_lang()) === $code ? ' selected' : '') . '>' . h($name) . '</option>';
- $body = $errs . sd_alert_box('info', t('setup_code_hint', ['file' => sd_name('setup')]), t('setup_code_title'))
- . '<form method="post" action="' . h(sd_url('setup')) . '" class="orb-mt-6" data-pow-form>' . sd_csrf_field() . sd_pow_fields('setup')
- . sd_field('code', t('setup_code'), 'text', ['required' => true, 'autocomplete' => 'off', 'spellcheck' => 'false', 'class' => 'orb-mono', 'placeholder' => 'XXXX-XXXX-XXXX-XXXX'])
- . sd_field('user', t('username'), 'text', ['required' => true, 'autocomplete' => 'username', 'minlength' => 3, 'maxlength' => 32, 'pattern' => '[A-Za-z0-9._\-]{3,32}', 'value' => (string)($old['user'] ?? '')], t('username_hint'))
- . sd_field('email', t('alert_email'), 'email', ['required' => true, 'autocomplete' => 'email', 'value' => (string)($old['email'] ?? '')], t('alert_email_hint'))
- . sd_field('password', t('password'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12], t('password_hint'))
- . sd_field('password2', t('password_repeat'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12])
- . '<div class="orb-form-group"><label class="orb-label" for="f-lang">' . h(t('language')) . '</label><select class="orb-select" id="f-lang" name="lang">' . $langOptions . '</select></div>'
- . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block">' . sd_icon('shield') . h(t('setup_submit')) . '</button></form>';
- echo sd_auth_card(t('setup_title'), t('setup_lead', ['file' => sd_name('env')]), $body);
- sd_close();
- }
-
- function sd_view_login(string $error, int $locked): void
- {
- sd_open(t('login_title'));
- $body = '';
- if ($locked > 0) {
- $body .= sd_alert_box('error', t('err_locked', ['min' => (string)max(1, (int)ceil($locked / 60))]));
- } elseif ($error !== '') {
- $body .= sd_alert_box('error', t($error));
- }
- $body .= '<form method="post" action="' . h(sd_url('login')) . '" class="orb-mt-2" data-pow-form>' . sd_csrf_field() . sd_pow_fields('login')
- . sd_field('user', t('username'), 'text', ['required' => true, 'autocomplete' => 'username', 'autofocus' => true, 'maxlength' => 32])
- . sd_field('password', t('password'), 'password', ['required' => true, 'autocomplete' => 'current-password', 'maxlength' => 256])
- . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block"' . ($locked > 0 ? ' disabled' : '') . '>' . sd_icon('lock') . h(t('login_submit')) . '</button>'
- . '<p class="orb-form-hint orb-text-center orb-mt-4">' . h(t('login_protected')) . '</p></form>';
- echo sd_auth_card(t('login_title'), t('login_lead'), $body);
- sd_close();
- }
-
- function sd_view_twofa(string $error): void
- {
- sd_open(t('twofa_title'));
- $body = ($error !== '' ? sd_alert_box('error', t($error)) : '')
- . '<form method="post" action="' . h(sd_url('2fa')) . '">' . sd_csrf_field()
- . sd_field('code', t('twofa_code'), 'text', ['required' => true, 'autocomplete' => 'one-time-code', 'autofocus' => true, 'inputmode' => 'text', 'maxlength' => 20, 'class' => 'orb-mono sd-code-input', 'spellcheck' => 'false'], t('twofa_hint'))
- . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block">' . sd_icon('shield') . h(t('twofa_submit')) . '</button></form>'
- . '<form method="post" action="' . h(sd_url('logout')) . '" class="orb-mt-3">' . sd_csrf_field() . '<button type="submit" class="orb-btn orb-btn-ghost orb-btn-block orb-btn-sm">' . h(t('cancel')) . '</button></form>';
- echo sd_auth_card(t('twofa_title'), t('twofa_lead'), $body);
- sd_close();
- }
-
- function sd_view_enroll(string $secret, string $uri, string $error): void
- {
- sd_open(t('enroll_title'));
- $grouped = trim(chunk_split($secret, 4, ' '));
- $body = ($error !== '' ? sd_alert_box('error', t($error)) : '')
- . '<ol class="sd-steps"><li>' . h(t('enroll_step1')) . '</li><li>' . h(t('enroll_step2')) . '</li><li>' . h(t('enroll_step3')) . '</li></ol>'
- . '<div class="sd-qr">' . sd_qr_svg($uri) . '</div>'
- . '<p class="orb-label orb-mt-4">' . h(t('enroll_manual')) . '</p><div class="sd-secret orb-mono" data-copy="' . h($secret) . '">' . h($grouped) . '<button type="button" class="orb-btn orb-btn-ghost orb-btn-icon orb-btn-xs" data-copy-btn aria-label="' . h(t('copy')) . '">' . sd_icon('copy', 14) . '</button></div>'
- . '<form method="post" action="' . h(sd_url('enroll')) . '" class="orb-mt-6">' . sd_csrf_field()
- . sd_field('code', t('twofa_code'), 'text', ['required' => true, 'autocomplete' => 'one-time-code', 'inputmode' => 'numeric', 'pattern' => '[0-9 ]{6,7}', 'maxlength' => 7, 'class' => 'orb-mono sd-code-input'])
- . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block">' . sd_icon('check') . h(t('enroll_submit')) . '</button></form>';
- echo sd_auth_card(t('enroll_title'), t('enroll_lead'), $body);
- sd_close();
- }
-
- function sd_view_recovery(array $codes, bool $app): void
- {
- $mode = $app ? 'app' : 'auth';
- sd_open(t('recovery_title'), $mode, 'settings');
- $list = '';
- foreach ($codes as $c) $list .= '<li class="orb-mono">' . h($c) . '</li>';
- $body = sd_alert_box('warn', t('recovery_warning'), t('recovery_once'))
- . '<ol class="sd-codes orb-mt-4" data-codes="' . h(implode("\n", $codes)) . '">' . $list . '</ol>'
- . '<div class="orb-flex orb-gap-2 orb-flex-wrap orb-mt-4"><button type="button" class="orb-btn orb-btn-secondary" data-codes-copy>' . sd_icon('copy') . h(t('copy')) . '</button>'
- . '<button type="button" class="orb-btn orb-btn-secondary" data-codes-download="syncdrop-recovery-codes.txt">' . sd_icon('download') . h(t('download')) . '</button></div>'
- . '<a class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block orb-mt-6" href="' . h(sd_url($app ? 'settings' : 'sync')) . '">' . sd_icon('check') . h(t('recovery_done')) . '</a>';
- if ($app) {
- echo '<section class="orb-card sd-narrow"><div class="orb-card-body"><p class="orb-lead orb-mb-4">' . h(t('recovery_lead')) . '</p>' . $body . '</div></section>';
- } else {
- echo sd_auth_card(t('recovery_title'), t('recovery_lead'), $body);
- }
- sd_close($mode);
- }
-
- function sd_view_sync(): void
- {
- sd_open(t('sync_title'), 'app', 'sync');
- $targets = [];
- foreach (sd_targets() as $id => $t) {
- $ok = sd_target((string)$id) !== null;
- $targets[] = ['id' => (string)$id, 'name' => (string)($t['name'] ?? $id), 'path' => (string)($t['path'] ?? ''), 'ok' => $ok];
- }
- if (!$targets) {
- echo sd_alert_box('warn', t('sync_no_targets')) . '<p class="orb-mt-4"><a class="orb-btn orb-btn-primary" href="' . h(sd_url('settings')) . '#targets">' . sd_icon('plus') . h(t('target_add')) . '</a></p>';
- sd_close('app');
- return;
- }
- $options = '';
- foreach ($targets as $t) $options .= '<option value="' . h($t['id']) . '"' . ($t['ok'] ? '' : ' disabled') . '>' . h($t['name'] . ' — ' . $t['path']) . ($t['ok'] ? '' : ' (' . t('target_unavailable') . ')') . '</option>';
- echo '<section class="sd-sync" data-sync data-chunk="' . SD_CHUNK . '" data-batch="' . SD_MAX_BATCH . '" data-max-files="' . SD_MAX_FILES . '" data-max-size="' . SD_MAX_FILE . '">'
- . '<div class="orb-grid sd-sync-grid orb-gap-4">'
- . '<div class="orb-card"><div class="orb-card-body">'
- . '<div class="orb-form-group"><label class="orb-label" for="sd-target">' . sd_icon('server', 14) . ' ' . h(t('sync_target')) . '</label><select class="orb-select" id="sd-target">' . $options . '</select></div>'
- . '<div class="sd-drop" data-drop tabindex="0"><span class="sd-drop-icon">' . sd_icon('folder', 28) . '</span><strong>' . h(t('sync_drop')) . '</strong><span class="orb-text-sm orb-text-muted">' . h(t('sync_drop_hint')) . '</span>'
- . '<button type="button" class="orb-btn orb-btn-secondary orb-mt-3" data-pick>' . sd_icon('folder') . h(t('sync_pick')) . '</button><input type="file" data-picker webkitdirectory directory multiple hidden></div>'
- . '<label class="orb-checkbox orb-mt-4"><input type="checkbox" data-strip checked><span>' . h(t('sync_strip')) . '</span></label>'
- . '<p class="orb-form-hint">' . h(t('sync_strip_hint')) . '</p>'
- . '</div></div>'
- . '<div class="orb-card"><div class="orb-card-body sd-status-card">'
- . '<div class="sd-stats"><div class="sd-stat"><span class="sd-stat-n" data-stat="n">0</span><span class="sd-stat-l">' . h(t('stat_new')) . '</span></div><div class="sd-stat"><span class="sd-stat-n" data-stat="c">0</span><span class="sd-stat-l">' . h(t('stat_changed')) . '</span></div><div class="sd-stat"><span class="sd-stat-n" data-stat="s">0</span><span class="sd-stat-l">' . h(t('stat_same')) . '</span></div><div class="sd-stat"><span class="sd-stat-n" data-stat="x">0</span><span class="sd-stat-l">' . h(t('stat_skipped')) . '</span></div></div>'
- . '<div class="sd-progress-wrap orb-mt-4" data-progress hidden><div class="orb-flex orb-justify-between orb-text-sm"><span data-phase></span><span data-percent>0%</span></div><div class="orb-progress orb-mt-2"><div class="orb-progress-bar" data-bar></div></div></div>'
- . '<div data-result class="orb-mt-4"></div>'
- . '<button type="button" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block orb-mt-4" data-send disabled>' . sd_icon('upload') . '<span data-send-label>' . h(t('js_send')) . '</span></button>'
- . '<p class="orb-form-hint orb-mt-3">' . sd_icon('shield', 12) . ' ' . h(t('sync_safety')) . '</p>'
- . '</div></div></div>'
- . '<div class="orb-card orb-mt-4" data-list-card hidden><div class="orb-card-header"><span class="orb-card-title">' . h(t('sync_changes')) . '</span><span class="orb-badge" data-list-count>0</span></div><div class="orb-table-wrap sd-list-wrap"><table class="orb-table orb-table-responsive"><thead><tr><th>' . h(t('col_file')) . '</th><th>' . h(t('col_size')) . '</th><th>' . h(t('col_status')) . '</th><th class="sd-view-cell"><span class="sd-sr">' . h(t('view_title')) . '</span></th></tr></thead><tbody data-list></tbody></table></div></div>'
- . '</section>'
- . '<template data-view-icon>' . sd_icon('code', 14) . '</template>'
- . '<div class="orb-modal-overlay" id="sd-viewer" aria-hidden="true"><div class="orb-modal sd-viewer" role="dialog" aria-modal="true" aria-labelledby="sd-viewer-title">'
- . '<div class="orb-modal-header"><div class="sd-viewer-head"><span class="orb-modal-title" id="sd-viewer-title" data-viewer-title></span><span class="orb-text-xs orb-text-muted" data-viewer-meta></span></div>'
- . '<button type="button" class="orb-modal-close" data-orb-modal-close aria-label="' . h(t('view_close')) . '">' . sd_icon('close') . '</button></div>'
- . '<div class="sd-viewer-bar"><div class="orb-tabs-list" role="tablist" data-viewer-tabs hidden>'
- . '<button type="button" class="orb-tab" role="tab" data-view="diff">' . h(t('view_diff')) . '</button><button type="button" class="orb-tab" role="tab" data-view="new">' . h(t('view_new')) . '</button><button type="button" class="orb-tab" role="tab" data-view="old">' . h(t('view_old')) . '</button></div>'
- . '<div class="sd-viewer-tools"><span class="sd-diff-stat" data-viewer-stat></span><label class="orb-checkbox"><input type="checkbox" data-viewer-wrap><span>' . h(t('view_wrap')) . '</span></label>'
- . '<button type="button" class="orb-btn orb-btn-ghost orb-btn-xs" data-viewer-copy hidden>' . sd_icon('copy', 14) . h(t('copy')) . '</button></div></div>'
- . '<div class="sd-viewer-body" data-viewer-body tabindex="0"></div></div></div>';
- sd_close('app');
- }
-
- function sd_view_history(): void
- {
- sd_open(t('history_title'), 'app', 'history');
- $items = sd_history();
- $targets = sd_targets();
- echo '<div data-orb-tabs><div class="orb-tabs-list" role="tablist"><button class="orb-tab" role="tab" aria-selected="true" aria-controls="tab-deploys">' . h(t('history_deploys')) . '</button><button class="orb-tab" role="tab" aria-controls="tab-security">' . h(t('history_security')) . '</button></div>';
- echo '<div class="orb-tab-panel" id="tab-deploys" role="tabpanel">';
- if (!$items) {
- echo '<div class="orb-panel orb-text-muted">' . h(t('history_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_target')) . '</th><th>' . h(t('col_files')) . '</th><th>' . h(t('col_size')) . '</th><th>' . h(t('col_status')) . '</th><th></th></tr></thead><tbody>';
- foreach ($items as $it) {
- $status = (string)($it['status'] ?? '');
- $pill = ['ok' => 'success', 'failed' => 'danger', 'rolled_back' => 'warning'][$status] ?? 'neutral';
- $action = '';
- if (sd_rollback_allowed($it)) {
- $action = '<form method="post" action="' . h(sd_url('history')) . '" data-confirm="' . h(t('js_confirm_rollback')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="rollback"><input type="hidden" name="id" value="' . h((string)$it['id']) . '"><button type="submit" class="orb-btn orb-btn-outline orb-btn-sm orb-tone-warning">' . sd_icon('undo', 14) . h(t('rollback')) . '</button></form>';
- }
- echo '<tr><td data-label="' . h(t('col_date')) . '"><time class="sd-time" datetime="' . h(gmdate('c', (int)$it['t'])) . '">' . h(gmdate('Y-m-d H:i', (int)$it['t'])) . ' UTC</time></td>'
- . '<td data-label="' . h(t('col_target')) . '">' . h((string)($targets[$it['target']]['name'] ?? $it['target'])) . '</td>'
- . '<td data-label="' . h(t('col_files')) . '">' . h(t('history_counts', ['n' => (string)(int)($it['n'] ?? 0), 'c' => (string)(int)($it['c'] ?? 0)])) . '</td>'
- . '<td data-label="' . h(t('col_size')) . '">' . h(sd_format_bytes((int)($it['bytes'] ?? 0))) . '</td>'
- . '<td data-label="' . h(t('col_status')) . '"><span class="orb-pill orb-pill-' . $pill . '">' . h(t('status_' . $status)) . '</span></td>'
- . '<td>' . $action . '</td></tr>';
- }
- echo '</tbody></table></div>';
- }
- echo '</div><div class="orb-tab-panel" id="tab-security" role="tabpanel" hidden>';
- $events = sd_audit_tail(80);
- if (!$events) {
- echo '<div class="orb-panel orb-text-muted">' . h(t('history_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_event')) . '</th><th>IP</th><th>' . h(t('col_device')) . '</th></tr></thead><tbody>';
- foreach ($events as $ev) {
- $danger = in_array($ev['e'] ?? '', ['login_failed', 'lockout', 'deploy_rolled_back', 'deploy_verify_failed', 'recovery_used', 'threat', 'ip_banned'], true);
- echo '<tr><td data-label="' . h(t('col_date')) . '">' . h(str_replace(['T', '+00:00'], [' ', ' UTC'], (string)($ev['t'] ?? ''))) . '</td>'
- . '<td data-label="' . h(t('col_event')) . '"><span class="orb-pill orb-pill-' . ($danger ? 'danger' : 'neutral') . '">' . h(t('ev_' . (string)($ev['e'] ?? ''))) . '</span></td>'
- . '<td data-label="IP" class="orb-mono">' . h((string)($ev['ip'] ?? '')) . '</td>'
- . '<td data-label="' . h(t('col_device')) . '" class="sd-ua">' . h((string)($ev['ua'] ?? '')) . '</td></tr>';
- }
- echo '</tbody></table></div>';
- }
- echo '</div></div>';
- sd_close('app');
- }
-
- function sd_view_settings(array $errors): void
- {
- sd_open(t('settings_title'), 'app', 'settings');
- $env = sd_env() ?? [];
- foreach ($errors as $e) echo sd_alert_box('error', t($e));
- $langOptions = '';
- foreach (SD_LANGS as $code => $name) $langOptions .= '<option value="' . h($code) . '"' . (sd_lang() === $code ? ' selected' : '') . '>' . h($name) . '</option>';
- $recoveryLeft = count((array)($env['SD_RECOVERY'] ?? []));
-
- echo '<div class="orb-grid orb-grid-2 orb-gap-4 sd-settings">';
- echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('mail', 16) . ' ' . h(t('settings_alerts')) . '</span></div><div class="orb-card-body"><form method="post" action="' . h(sd_url('settings')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="alerts">'
- . sd_field('email', t('alert_email'), 'email', ['required' => true, 'value' => (string)($env['SD_EMAIL'] ?? ''), 'autocomplete' => 'email'])
- . '<label class="orb-checkbox orb-mb-4"><input type="checkbox" name="alerts" value="1"' . (!empty($env['SD_ALERTS']) ? ' checked' : '') . '><span>' . h(t('settings_alerts_on')) . '</span></label>'
- . sd_field('current', t('password_current'), 'password', ['required' => true, 'autocomplete' => 'current-password'])
- . '<div class="orb-flex orb-gap-2 orb-flex-wrap"><button class="orb-btn orb-btn-primary" type="submit">' . h(t('save')) . '</button><button class="orb-btn orb-btn-secondary" type="submit" name="test" value="1">' . h(t('settings_test_mail')) . '</button></div></form></div></section>';
-
- echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('key', 16) . ' ' . h(t('settings_password')) . '</span></div><div class="orb-card-body"><form method="post" action="' . h(sd_url('settings')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="password">'
- . sd_field('current', t('password_current'), 'password', ['required' => true, 'autocomplete' => 'current-password'])
- . sd_field('password', t('password_new'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12], t('password_hint'))
- . sd_field('password2', t('password_repeat'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12])
- . '<button class="orb-btn orb-btn-primary" type="submit">' . h(t('settings_password_save')) . '</button></form></div></section>';
-
- echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('shield', 16) . ' ' . h(t('settings_2fa')) . '</span><span class="orb-pill orb-pill-success">' . h(t('settings_2fa_on')) . '</span></div><div class="orb-card-body">'
- . '<p class="orb-text-sm orb-text-muted orb-mb-4">' . h(t('settings_recovery_left', ['n' => (string)$recoveryLeft])) . '</p>'
- . '<form method="post" action="' . h(sd_url('settings')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="recovery">'
- . sd_field('code', t('twofa_code'), 'text', ['required' => true, 'inputmode' => 'numeric', 'autocomplete' => 'one-time-code', 'maxlength' => 7, 'class' => 'orb-mono'])
- . '<button class="orb-btn orb-btn-secondary" type="submit">' . sd_icon('key') . h(t('settings_recovery_new')) . '</button></form></div></section>';
-
- echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('lock', 16) . ' ' . h(t('settings_sessions')) . '</span></div><div class="orb-card-body">'
- . '<p class="orb-text-sm orb-text-muted orb-mb-4">' . h(t('settings_sessions_hint')) . '</p>'
- . '<form method="post" action="' . h(sd_url('settings')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="sessions"><button class="orb-btn orb-btn-secondary" type="submit">' . sd_icon('logout') . h(t('settings_sessions_btn')) . '</button></form>'
- . '<hr class="orb-divider"><form method="post" action="' . h(sd_url('settings')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="lang"><div class="orb-form-group"><label class="orb-label" for="f-ulang">' . sd_icon('globe', 14) . ' ' . h(t('language')) . '</label><select class="orb-select" id="f-ulang" name="lang">' . $langOptions . '</select></div><button class="orb-btn orb-btn-secondary" type="submit">' . h(t('save')) . '</button></form>'
- . '</div></section></div>';
-
- echo '<section class="orb-card orb-mt-4" id="targets"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('server', 16) . ' ' . h(t('settings_targets')) . '</span></div><div class="orb-card-body">';
- foreach (sd_targets() as $id => $t) {
- $ok = sd_target((string)$id) !== null;
- echo '<form method="post" action="' . h(sd_url('settings')) . '" class="sd-target">' . sd_csrf_field() . '<input type="hidden" name="action" value="target_save"><input type="hidden" name="id" value="' . h((string)$id) . '">'
- . '<div class="orb-flex orb-justify-between orb-items-center orb-gap-3 orb-flex-wrap orb-mb-3"><div><strong>' . h((string)($t['name'] ?? $id)) . '</strong><div class="orb-mono orb-text-xs orb-text-muted">' . h((string)($t['path'] ?? '')) . '</div></div>'
- . '<span class="orb-pill orb-pill-' . ($ok ? 'success' : 'danger') . '">' . h($ok ? t('target_ok') : t('target_unavailable')) . '</span></div>'
- . sd_field('name', t('target_name'), 'text', ['required' => true, 'maxlength' => 60, 'value' => (string)($t['name'] ?? '')])
- . '<div class="orb-form-group"><label class="orb-label" for="ex-' . h((string)$id) . '">' . h(t('target_exclude')) . '</label><textarea class="orb-textarea orb-mono" id="ex-' . h((string)$id) . '" name="exclude" rows="5" spellcheck="false">' . h(implode("\n", (array)($t['exclude'] ?? []))) . '</textarea><p class="orb-form-hint">' . h(t('target_exclude_hint')) . '</p></div>'
- . '<div class="orb-flex orb-gap-2 orb-flex-wrap"><button class="orb-btn orb-btn-primary orb-btn-sm" type="submit">' . h(t('save')) . '</button><button class="orb-btn orb-btn-outline orb-btn-sm orb-tone-danger" type="submit" name="remove" value="1" data-confirm="' . h(t('target_remove_confirm')) . '">' . sd_icon('trash', 14) . h(t('target_remove')) . '</button></div></form>';
- }
- echo '<form method="post" action="' . h(sd_url('settings')) . '" class="sd-target sd-target-new">' . sd_csrf_field() . '<input type="hidden" name="action" value="target_add"><h3 class="orb-h3 orb-mb-3">' . sd_icon('plus', 16) . ' ' . h(t('target_add')) . '</h3>'
- . '<div class="orb-grid orb-grid-2 orb-gap-4">' . sd_field('name', t('target_name'), 'text', ['required' => true, 'maxlength' => 60, 'placeholder' => 'example.com'])
- . sd_field('path', t('target_path'), 'text', ['required' => true, 'class' => 'orb-mono', 'spellcheck' => 'false', 'placeholder' => sd_docroot()], t('target_path_hint')) . '</div>'
- . '<button class="orb-btn orb-btn-primary" type="submit">' . sd_icon('plus') . h(t('target_add')) . '</button></form>';
- echo '</div></section>';
- sd_close('app');
- }
-