WebOrbiton
v1.3.0

AtheraStudio SyncDrop

379 lines · 34.6 KB
  1. <?php
  2. declare(strict_types=1);
  3. ​
  4. defined('SD_BOOT') || exit;
  5. ​
  6. const SD_ICONS = [
  7. '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"/>',
  8. 'history' => '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/>',
  9. '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"/>',
  10. '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"/>',
  11. '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"/>',
  12. '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"/>',
  13. '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"/>',
  14. 'check' => '<path d="M20 6 9 17l-5-5"/>',
  15. '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"/>',
  16. 'info' => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
  17. '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"/>',
  18. 'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
  19. '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"/>',
  20. 'moon' => '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>',
  21. '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"/>',
  22. '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"/>',
  23. '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"/>',
  24. '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"/>',
  25. 'plus' => '<path d="M12 5v14M5 12h14"/>',
  26. 'undo' => '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-3"/>',
  27. 'mail' => '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/>',
  28. '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"/>',
  29. 'code' => '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
  30. 'close' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
  31. ];
  32. ​
  33. 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';
  34. ​
  35. function sd_logo(int $size): string
  36. {
  37. return '<svg class="sd-logo" width="' . $size . '" height="' . $size . '" viewBox="0 0 100 100" aria-hidden="true"><path d="' . SD_LOGO . '"/></svg>';
  38. }
  39. ​
  40. function sd_icon(string $name, int $size = 18): string
  41. {
  42. 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>';
  43. }
  44. ​
  45. function sd_asset(string $file): string
  46. {
  47. $path = SD_ROOT . '/assets/' . $file;
  48. return sd_url_base() . 'assets/' . $file . '?v=' . (is_file($path) ? substr(md5((string)filemtime($path) . SD_VERSION), 0, 8) : SD_VERSION);
  49. }
  50. ​
  51. function sd_js_strings(): array
  52. {
  53. $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'];
  54. $out = [];
  55. foreach ($keys as $k) $out[$k] = t($k);
  56. return $out;
  57. }
  58. ​
  59. function sd_open(string $title, string $mode = 'auth', string $active = ''): void
  60. {
  61. $lang = sd_lang();
  62. echo '<!doctype html><html lang="' . h($lang) . '" class="orb-reset orb-app orb-depth" data-orb-accent="fuchsia"><head><meta charset="utf-8">'
  63. . '<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow"><meta name="referrer" content="no-referrer">'
  64. . '<meta name="theme-color" content="#f8fafc"><title>' . h($title) . ' · ' . h(SD_NAME) . '</title>'
  65. . '<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')) . '">'
  66. . '<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>'
  67. . '<link rel="stylesheet" href="' . h(sd_asset('orbitarium.css')) . '"><link rel="stylesheet" href="' . h(sd_asset('app.css')) . '">'
  68. . '<script src="' . h(sd_asset('theme.js')) . '"></script>'
  69. . '<script src="' . h(sd_asset('orbitarium.js')) . '" defer></script><script src="' . h(sd_asset('app.js')) . '" defer></script></head>'
  70. . '<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)) . '">'
  71. . '<noscript><div class="sd-noscript">' . h(t('noscript')) . '</div></noscript>';
  72. if ($mode === 'app') {
  73. $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')]];
  74. $fresh = sd_update_available();
  75. 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>';
  76. foreach ($nav as [$page, $icon, $label]) {
  77. 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>';
  78. }
  79. 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>'
  80. . '</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>';
  81. } else {
  82. 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>';
  83. }
  84. $flash = sd_flash();
  85. if ($flash) echo sd_alert_box($flash['type'], $flash['msg']);
  86. }
  87. ​
  88. function sd_close(string $mode = 'auth'): void
  89. {
  90. if ($mode === 'app') echo '</main></div>';
  91. else echo '<p class="sd-auth-foot orb-text-xs orb-text-subtle">' . h(SD_NAME) . ' ' . h(SD_VERSION) . '</p></main>';
  92. echo '</body></html>';
  93. }
  94. ​
  95. function sd_theme_button(): string
  96. {
  97. 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>';
  98. }
  99. ​
  100. function sd_lang_switch(): string
  101. {
  102. $out = '<form method="post" action="' . h(sd_url('lang')) . '" class="sd-lang"><input type="hidden" name="_csrf" value="' . h(sd_csrf()) . '">';
  103. foreach (SD_LANGS as $code => $name) {
  104. $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>';
  105. }
  106. return $out . '</form>';
  107. }
  108. ​
  109. function sd_alert_box(string $type, string $message, string $title = ''): string
  110. {
  111. $tone = ['ok' => 'success', 'error' => 'danger', 'warn' => 'warning'][$type] ?? 'info';
  112. $icon = ['success' => 'check', 'danger' => 'alert', 'warning' => 'alert'][$tone] ?? 'info';
  113. 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">'
  114. . ($title !== '' ? '<div class="orb-alert-title">' . h($title) . '</div>' : '') . '<div class="orb-alert-text">' . h($message) . '</div></div></div>';
  115. }
  116. ​
  117. function sd_csrf_field(): string
  118. {
  119. return '<input type="hidden" name="_csrf" value="' . h(sd_csrf()) . '">';
  120. }
  121. ​
  122. function sd_pow_fields(string $form): string
  123. {
  124. return '<input type="hidden" name="_pow" value="' . h(sd_pow_issue($form)) . '"><input type="hidden" name="_nonce" value="">'
  125. . '<div class="sd-hp" aria-hidden="true"><label>Website<input type="text" name="website" tabindex="-1" autocomplete="off"></label></div>';
  126. }
  127. ​
  128. function sd_field(string $name, string $label, string $type = 'text', array $attrs = [], string $hint = ''): string
  129. {
  130. $id = (string)($attrs['id'] ?? 'f-' . $name);
  131. unset($attrs['id']);
  132. $extra = '';
  133. foreach ($attrs as $k => $v) $extra .= $v === true ? ' ' . h($k) : ' ' . h($k) . '="' . h((string)$v) . '"';
  134. 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 . '>'
  135. . ($hint !== '' ? '<p class="orb-form-hint">' . h($hint) . '</p>' : '') . '</div>';
  136. }
  137. ​
  138. function sd_auth_card(string $title, string $lead, string $body): string
  139. {
  140. return '<section class="orb-card sd-auth-card"><div class="orb-card-body"><h1 class="orb-h2">' . h($title) . '</h1>'
  141. . ($lead !== '' ? '<p class="orb-lead orb-mt-2 orb-mb-6">' . h($lead) . '</p>' : '') . $body . '</div></section>';
  142. }
  143. ​
  144. function sd_view_fatal(string $title, string $message): never
  145. {
  146. http_response_code(503);
  147. sd_open($title);
  148. echo sd_auth_card($title, '', sd_alert_box('error', $message));
  149. sd_close();
  150. exit;
  151. }
  152. ​
  153. function sd_view_setup(array $errors, array $old): void
  154. {
  155. sd_open(t('setup_title'));
  156. $errs = '';
  157. foreach ($errors as $e) $errs .= sd_alert_box('error', t($e));
  158. $langOptions = '';
  159. foreach (SD_LANGS as $code => $name) $langOptions .= '<option value="' . h($code) . '"' . (($old['lang'] ?? sd_lang()) === $code ? ' selected' : '') . '>' . h($name) . '</option>';
  160. $body = $errs . sd_alert_box('info', t('setup_code_hint', ['file' => sd_name('setup')]), t('setup_code_title'))
  161. . '<form method="post" action="' . h(sd_url('setup')) . '" class="orb-mt-6" data-pow-form>' . sd_csrf_field() . sd_pow_fields('setup')
  162. . sd_field('code', t('setup_code'), 'text', ['required' => true, 'autocomplete' => 'off', 'spellcheck' => 'false', 'class' => 'orb-mono', 'placeholder' => 'XXXX-XXXX-XXXX-XXXX'])
  163. . 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'))
  164. . sd_field('email', t('alert_email'), 'email', ['required' => true, 'autocomplete' => 'email', 'value' => (string)($old['email'] ?? '')], t('alert_email_hint'))
  165. . sd_field('password', t('password'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12], t('password_hint'))
  166. . sd_field('password2', t('password_repeat'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12])
  167. . '<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>'
  168. . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block">' . sd_icon('shield') . h(t('setup_submit')) . '</button></form>';
  169. echo sd_auth_card(t('setup_title'), t('setup_lead', ['file' => sd_name('env')]), $body);
  170. sd_close();
  171. }
  172. ​
  173. function sd_view_login(string $error, int $locked): void
  174. {
  175. sd_open(t('login_title'));
  176. $body = '';
  177. if ($locked > 0) {
  178. $body .= sd_alert_box('error', t('err_locked', ['min' => (string)max(1, (int)ceil($locked / 60))]));
  179. } elseif ($error !== '') {
  180. $body .= sd_alert_box('error', t($error));
  181. }
  182. $body .= '<form method="post" action="' . h(sd_url('login')) . '" class="orb-mt-2" data-pow-form>' . sd_csrf_field() . sd_pow_fields('login')
  183. . sd_field('user', t('username'), 'text', ['required' => true, 'autocomplete' => 'username', 'autofocus' => true, 'maxlength' => 32])
  184. . sd_field('password', t('password'), 'password', ['required' => true, 'autocomplete' => 'current-password', 'maxlength' => 256])
  185. . '<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>'
  186. . '<p class="orb-form-hint orb-text-center orb-mt-4">' . h(t('login_protected')) . '</p></form>';
  187. echo sd_auth_card(t('login_title'), t('login_lead'), $body);
  188. sd_close();
  189. }
  190. ​
  191. function sd_view_twofa(string $error): void
  192. {
  193. sd_open(t('twofa_title'));
  194. $body = ($error !== '' ? sd_alert_box('error', t($error)) : '')
  195. . '<form method="post" action="' . h(sd_url('2fa')) . '">' . sd_csrf_field()
  196. . 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'))
  197. . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block">' . sd_icon('shield') . h(t('twofa_submit')) . '</button></form>'
  198. . '<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>';
  199. echo sd_auth_card(t('twofa_title'), t('twofa_lead'), $body);
  200. sd_close();
  201. }
  202. ​
  203. function sd_view_enroll(string $secret, string $uri, string $error): void
  204. {
  205. sd_open(t('enroll_title'));
  206. $grouped = trim(chunk_split($secret, 4, ' '));
  207. $body = ($error !== '' ? sd_alert_box('error', t($error)) : '')
  208. . '<ol class="sd-steps"><li>' . h(t('enroll_step1')) . '</li><li>' . h(t('enroll_step2')) . '</li><li>' . h(t('enroll_step3')) . '</li></ol>'
  209. . '<div class="sd-qr">' . sd_qr_svg($uri) . '</div>'
  210. . '<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>'
  211. . '<form method="post" action="' . h(sd_url('enroll')) . '" class="orb-mt-6">' . sd_csrf_field()
  212. . 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'])
  213. . '<button type="submit" class="orb-btn orb-btn-primary orb-btn-lg orb-btn-block">' . sd_icon('check') . h(t('enroll_submit')) . '</button></form>';
  214. echo sd_auth_card(t('enroll_title'), t('enroll_lead'), $body);
  215. sd_close();
  216. }
  217. ​
  218. function sd_view_recovery(array $codes, bool $app): void
  219. {
  220. $mode = $app ? 'app' : 'auth';
  221. sd_open(t('recovery_title'), $mode, 'settings');
  222. $list = '';
  223. foreach ($codes as $c) $list .= '<li class="orb-mono">' . h($c) . '</li>';
  224. $body = sd_alert_box('warn', t('recovery_warning'), t('recovery_once'))
  225. . '<ol class="sd-codes orb-mt-4" data-codes="' . h(implode("\n", $codes)) . '">' . $list . '</ol>'
  226. . '<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>'
  227. . '<button type="button" class="orb-btn orb-btn-secondary" data-codes-download="syncdrop-recovery-codes.txt">' . sd_icon('download') . h(t('download')) . '</button></div>'
  228. . '<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>';
  229. if ($app) {
  230. 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>';
  231. } else {
  232. echo sd_auth_card(t('recovery_title'), t('recovery_lead'), $body);
  233. }
  234. sd_close($mode);
  235. }
  236. ​
  237. function sd_view_sync(): void
  238. {
  239. sd_open(t('sync_title'), 'app', 'sync');
  240. $targets = [];
  241. foreach (sd_targets() as $id => $t) {
  242. $ok = sd_target((string)$id) !== null;
  243. $targets[] = ['id' => (string)$id, 'name' => (string)($t['name'] ?? $id), 'path' => (string)($t['path'] ?? ''), 'ok' => $ok];
  244. }
  245. if (!$targets) {
  246. 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>';
  247. sd_close('app');
  248. return;
  249. }
  250. $options = '';
  251. foreach ($targets as $t) $options .= '<option value="' . h($t['id']) . '"' . ($t['ok'] ? '' : ' disabled') . '>' . h($t['name'] . ' — ' . $t['path']) . ($t['ok'] ? '' : ' (' . t('target_unavailable') . ')') . '</option>';
  252. 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 . '">'
  253. . '<div class="orb-grid sd-sync-grid orb-gap-4">'
  254. . '<div class="orb-card"><div class="orb-card-body">'
  255. . '<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>'
  256. . '<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>'
  257. . '<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>'
  258. . '<label class="orb-checkbox orb-mt-4"><input type="checkbox" data-strip checked><span>' . h(t('sync_strip')) . '</span></label>'
  259. . '<p class="orb-form-hint">' . h(t('sync_strip_hint')) . '</p>'
  260. . '</div></div>'
  261. . '<div class="orb-card"><div class="orb-card-body sd-status-card">'
  262. . '<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>'
  263. . '<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>'
  264. . '<div data-result class="orb-mt-4"></div>'
  265. . '<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>'
  266. . '<p class="orb-form-hint orb-mt-3">' . sd_icon('shield', 12) . ' ' . h(t('sync_safety')) . '</p>'
  267. . '</div></div></div>'
  268. . '<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>'
  269. . '</section>'
  270. . '<template data-view-icon>' . sd_icon('code', 14) . '</template>'
  271. . '<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">'
  272. . '<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>'
  273. . '<button type="button" class="orb-modal-close" data-orb-modal-close aria-label="' . h(t('view_close')) . '">' . sd_icon('close') . '</button></div>'
  274. . '<div class="sd-viewer-bar"><div class="orb-tabs-list" role="tablist" data-viewer-tabs hidden>'
  275. . '<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>'
  276. . '<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>'
  277. . '<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>'
  278. . '<div class="sd-viewer-body" data-viewer-body tabindex="0"></div></div></div>';
  279. sd_close('app');
  280. }
  281. ​
  282. function sd_view_history(): void
  283. {
  284. sd_open(t('history_title'), 'app', 'history');
  285. $items = sd_history();
  286. $targets = sd_targets();
  287. 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>';
  288. echo '<div class="orb-tab-panel" id="tab-deploys" role="tabpanel">';
  289. if (!$items) {
  290. echo '<div class="orb-panel orb-text-muted">' . h(t('history_empty')) . '</div>';
  291. } else {
  292. 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>';
  293. foreach ($items as $it) {
  294. $status = (string)($it['status'] ?? '');
  295. $pill = ['ok' => 'success', 'failed' => 'danger', 'rolled_back' => 'warning'][$status] ?? 'neutral';
  296. $action = '';
  297. if (sd_rollback_allowed($it)) {
  298. $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>';
  299. }
  300. 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>'
  301. . '<td data-label="' . h(t('col_target')) . '">' . h((string)($targets[$it['target']]['name'] ?? $it['target'])) . '</td>'
  302. . '<td data-label="' . h(t('col_files')) . '">' . h(t('history_counts', ['n' => (string)(int)($it['n'] ?? 0), 'c' => (string)(int)($it['c'] ?? 0)])) . '</td>'
  303. . '<td data-label="' . h(t('col_size')) . '">' . h(sd_format_bytes((int)($it['bytes'] ?? 0))) . '</td>'
  304. . '<td data-label="' . h(t('col_status')) . '"><span class="orb-pill orb-pill-' . $pill . '">' . h(t('status_' . $status)) . '</span></td>'
  305. . '<td>' . $action . '</td></tr>';
  306. }
  307. echo '</tbody></table></div>';
  308. }
  309. echo '</div><div class="orb-tab-panel" id="tab-security" role="tabpanel" hidden>';
  310. $events = sd_audit_tail(80);
  311. if (!$events) {
  312. echo '<div class="orb-panel orb-text-muted">' . h(t('history_empty')) . '</div>';
  313. } else {
  314. 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>';
  315. foreach ($events as $ev) {
  316. $danger = in_array($ev['e'] ?? '', ['login_failed', 'lockout', 'deploy_rolled_back', 'deploy_verify_failed', 'recovery_used', 'threat', 'ip_banned'], true);
  317. echo '<tr><td data-label="' . h(t('col_date')) . '">' . h(str_replace(['T', '+00:00'], [' ', ' UTC'], (string)($ev['t'] ?? ''))) . '</td>'
  318. . '<td data-label="' . h(t('col_event')) . '"><span class="orb-pill orb-pill-' . ($danger ? 'danger' : 'neutral') . '">' . h(t('ev_' . (string)($ev['e'] ?? ''))) . '</span></td>'
  319. . '<td data-label="IP" class="orb-mono">' . h((string)($ev['ip'] ?? '')) . '</td>'
  320. . '<td data-label="' . h(t('col_device')) . '" class="sd-ua">' . h((string)($ev['ua'] ?? '')) . '</td></tr>';
  321. }
  322. echo '</tbody></table></div>';
  323. }
  324. echo '</div></div>';
  325. sd_close('app');
  326. }
  327. ​
  328. function sd_view_settings(array $errors): void
  329. {
  330. sd_open(t('settings_title'), 'app', 'settings');
  331. $env = sd_env() ?? [];
  332. foreach ($errors as $e) echo sd_alert_box('error', t($e));
  333. $langOptions = '';
  334. foreach (SD_LANGS as $code => $name) $langOptions .= '<option value="' . h($code) . '"' . (sd_lang() === $code ? ' selected' : '') . '>' . h($name) . '</option>';
  335. $recoveryLeft = count((array)($env['SD_RECOVERY'] ?? []));
  336. ​
  337. echo '<div class="orb-grid orb-grid-2 orb-gap-4 sd-settings">';
  338. 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">'
  339. . sd_field('email', t('alert_email'), 'email', ['required' => true, 'value' => (string)($env['SD_EMAIL'] ?? ''), 'autocomplete' => 'email'])
  340. . '<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>'
  341. . sd_field('current', t('password_current'), 'password', ['required' => true, 'autocomplete' => 'current-password'])
  342. . '<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>';
  343. ​
  344. 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">'
  345. . sd_field('current', t('password_current'), 'password', ['required' => true, 'autocomplete' => 'current-password'])
  346. . sd_field('password', t('password_new'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12], t('password_hint'))
  347. . sd_field('password2', t('password_repeat'), 'password', ['required' => true, 'autocomplete' => 'new-password', 'minlength' => 12])
  348. . '<button class="orb-btn orb-btn-primary" type="submit">' . h(t('settings_password_save')) . '</button></form></div></section>';
  349. ​
  350. 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">'
  351. . '<p class="orb-text-sm orb-text-muted orb-mb-4">' . h(t('settings_recovery_left', ['n' => (string)$recoveryLeft])) . '</p>'
  352. . '<form method="post" action="' . h(sd_url('settings')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="recovery">'
  353. . sd_field('code', t('twofa_code'), 'text', ['required' => true, 'inputmode' => 'numeric', 'autocomplete' => 'one-time-code', 'maxlength' => 7, 'class' => 'orb-mono'])
  354. . '<button class="orb-btn orb-btn-secondary" type="submit">' . sd_icon('key') . h(t('settings_recovery_new')) . '</button></form></div></section>';
  355. ​
  356. 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">'
  357. . '<p class="orb-text-sm orb-text-muted orb-mb-4">' . h(t('settings_sessions_hint')) . '</p>'
  358. . '<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>'
  359. . '<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>'
  360. . '</div></section></div>';
  361. ​
  362. 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">';
  363. foreach (sd_targets() as $id => $t) {
  364. $ok = sd_target((string)$id) !== null;
  365. 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) . '">'
  366. . '<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>'
  367. . '<span class="orb-pill orb-pill-' . ($ok ? 'success' : 'danger') . '">' . h($ok ? t('target_ok') : t('target_unavailable')) . '</span></div>'
  368. . sd_field('name', t('target_name'), 'text', ['required' => true, 'maxlength' => 60, 'value' => (string)($t['name'] ?? '')])
  369. . '<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>'
  370. . '<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>';
  371. }
  372. 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>'
  373. . '<div class="orb-grid orb-grid-2 orb-gap-4">' . sd_field('name', t('target_name'), 'text', ['required' => true, 'maxlength' => 60, 'placeholder' => 'example.com'])
  374. . sd_field('path', t('target_path'), 'text', ['required' => true, 'class' => 'orb-mono', 'spellcheck' => 'false', 'placeholder' => sd_docroot()], t('target_path_hint')) . '</div>'
  375. . '<button class="orb-btn orb-btn-primary" type="submit">' . sd_icon('plus') . h(t('target_add')) . '</button></form>';
  376. echo '</div></section>';
  377. sd_close('app');
  378. }
  379. ​