WebOrbiton
v1.3.0

AtheraStudio SyncDrop

454 lines · 23.1 KB
  1. <?php
  2. declare(strict_types=1);
  3. ​
  4. defined('SD_BOOT') || exit;
  5. ​
  6. const SD_BAN_THRESHOLD = 100;
  7. const SD_BAN_WINDOW = 86400;
  8. const SD_BAN_STEPS = [86400, 604800, 2592000];
  9. const SD_BAN_MEMORY = 7776000;
  10. const SD_BAN_MAX = 5000;
  11. const SD_ALLOW_MAX = 200;
  12. const SD_TRUST_TTL = 2592000;
  13. const SD_BAN_MAIL_LIMIT = 5;
  14. const SD_BAN_DURATIONS = [3600, 86400, 604800, 2592000, 0];
  15. const SD_THREATS = [
  16. 'scanner' => 100,
  17. 'signature' => 50,
  18. 'honeypot' => 50,
  19. 'pow_forged' => 30,
  20. 'pow_invalid' => 20,
  21. 'probe' => 20,
  22. 'method' => 30,
  23. 'host' => 20,
  24. 'api_anon' => 25,
  25. 'lockout' => 30,
  26. 'fail_setup_code' => 15,
  27. 'fail_password' => 10,
  28. 'fail_totp' => 10,
  29. 'fail_antibot' => 10,
  30. ];
  31. const SD_SCANNER_UA = '/(sqlmap|nikto|nmap|masscan|zgrab|wpscan|nuclei|dirbuster|gobuster|feroxbuster|ffuf|wfuzz|acunetix|netsparker|jaeles|whatweb|openvas|nessus|hydra|havij|commix|xsstrike|arachni|skipfish|w3af|zmeu|morfeus|fimap|dirsearch|nmap scripting engine)/i';
  32. const SD_ATTACK_PATTERNS = [
  33. '#\.\.(/|\\\\)#',
  34. '#(/etc/(passwd|shadow|hosts|group)|win\.ini|boot\.ini|/proc/self/)#',
  35. '#<\s*/?\s*(script|iframe|object|embed|svg|img|body)\b#',
  36. '#(javascript|vbscript|livescript)\s*:#',
  37. '#\bon(error|load|mouseover|focus|toggle|animationstart)\s*=#',
  38. '#\bunion\b[\s/*+()]{1,40}(all\s+)?select\b#',
  39. '#\b(sleep|benchmark|pg_sleep|extractvalue|updatexml)\s*\(#',
  40. '#\bwaitfor\s+delay\b#',
  41. '#[\'"]\s*(or|and|\|\||&&)\s*[\'"]?\s*\d+\s*[\'"]?\s*(=|<|>|like)#',
  42. '#\binformation_schema\b#',
  43. '#\$\{\s*(jndi|env|sys|java|lower|upper|::-)#',
  44. '#\b(base64_decode|shell_exec|passthru|proc_open|popen|assert|eval|system|exec|phpinfo|file_put_contents)\s*\(#',
  45. '#\b(php|phar|expect|zip|glob|data):/{1,2}#',
  46. '#/(usr/)?bin/(ba|z|da|k)?sh\b#',
  47. '#\b(wget|curl)\s+(-\S+\s+)*https?://#',
  48. '#(wp-(login|admin|content|includes|config|json)|xmlrpc\.php|phpmyadmin|/pma/|adminer|/\.env|\.git/|/\.aws|/\.ssh|cgi-bin|vendor/phpunit|eval-stdin|server-status|/actuator|hnap1|boaform|/owa/|\.ds_store|web\.config|\.htpasswd|/shell\.php|/(backup|dump|db|database|site)\.(zip|sql|tar|gz|rar|7z))#',
  49. ];
  50. ​
  51. function sd_ban_key(string $ip): string
  52. {
  53. if (!str_contains($ip, ':')) return $ip;
  54. return sd_cidr_parse($ip . '/64')['text'] ?? $ip;
  55. }
  56. ​
  57. function sd_shield_match(array $list, string $ip): ?array
  58. {
  59. $now = time();
  60. foreach ([$ip, sd_ban_key($ip)] as $key) {
  61. $entry = $list[$key] ?? null;
  62. if (is_array($entry) && sd_shield_active($entry, $now)) return [$key, $entry];
  63. }
  64. foreach ($list as $key => $entry) {
  65. $key = (string)$key;
  66. if (!str_contains($key, '/') || !is_array($entry) || !sd_shield_active($entry, $now)) continue;
  67. if (sd_cidr_contains($key, $ip)) return [$key, $entry];
  68. }
  69. return null;
  70. }
  71. ​
  72. function sd_shield_active(array $entry, int $now): bool
  73. {
  74. $until = (int)($entry['until'] ?? 0);
  75. return $until === 0 || $until > $now;
  76. }
  77. ​
  78. function sd_shield_allowed(?array $state = null): bool
  79. {
  80. $state ??= sd_read_state('bans');
  81. return sd_shield_match($state['allow'] ?? [], sd_ip()) !== null;
  82. }
  83. ​
  84. function sd_shield_trusted(?array $state = null): bool
  85. {
  86. $state ??= sd_read_state('bans');
  87. return (int)($state['trust'][sd_ban_key(sd_ip())] ?? 0) > time();
  88. }
  89. ​
  90. function sd_shield_exempt(): bool
  91. {
  92. if (session_status() === PHP_SESSION_ACTIVE && ($_SESSION['stage'] ?? '') === 'full') return true;
  93. $state = sd_read_state('bans');
  94. return sd_shield_allowed($state) || sd_shield_trusted($state);
  95. }
  96. ​
  97. function sd_shield_deny(int $code, string $message = ''): never
  98. {
  99. http_response_code($code);
  100. header('Content-Type: text/plain; charset=utf-8');
  101. $texts = [400 => 'Bad Request', 403 => 'Forbidden', 404 => 'Not Found', 405 => 'Method Not Allowed'];
  102. echo $message !== '' ? $message : $code . ' ' . ($texts[$code] ?? 'Error');
  103. exit;
  104. }
  105. ​
  106. function sd_shield_text(string $raw): string
  107. {
  108. $once = rawurldecode($raw);
  109. $twice = rawurldecode($once);
  110. return strtolower($raw . "\n" . $once . "\n" . $twice . "\n" . html_entity_decode($twice, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
  111. }
  112. ​
  113. function sd_shield_signature(string $text): bool
  114. {
  115. foreach (SD_ATTACK_PATTERNS as $re) {
  116. if (preg_match($re, $text)) return true;
  117. }
  118. return false;
  119. }
  120. ​
  121. function sd_shield_prune(array &$s, int $now): void
  122. {
  123. foreach (['bans', 'allow', 'trust', 'score', 'level'] as $k) {
  124. if (!isset($s[$k]) || !is_array($s[$k])) $s[$k] = [];
  125. }
  126. foreach ($s['bans'] as $k => $b) {
  127. if (!is_array($b) || !sd_shield_active($b, $now)) unset($s['bans'][$k]);
  128. }
  129. foreach ($s['trust'] as $k => $until) {
  130. if ((int)$until <= $now) unset($s['trust'][$k]);
  131. }
  132. foreach ($s['score'] as $k => $events) {
  133. $events = array_values(array_filter((array)$events, static fn($e) => is_array($e) && $now - (int)($e[0] ?? 0) < SD_BAN_WINDOW));
  134. if ($events) $s['score'][$k] = $events;
  135. else unset($s['score'][$k]);
  136. }
  137. foreach ($s['level'] as $k => $l) {
  138. if (!is_array($l) || $now - (int)($l[1] ?? 0) > SD_BAN_MEMORY) unset($s['level'][$k]);
  139. }
  140. foreach (['bans' => 'at', 'level' => 1, 'trust' => null, 'score' => null] as $k => $field) {
  141. if (count($s[$k]) <= SD_BAN_MAX) continue;
  142. uasort($s[$k], static function ($a, $b) use ($field): int {
  143. $ta = is_array($a) ? (int)($field === null ? (end($a)[0] ?? 0) : ($a[$field] ?? 0)) : (int)$a;
  144. $tb = is_array($b) ? (int)($field === null ? (end($b)[0] ?? 0) : ($b[$field] ?? 0)) : (int)$b;
  145. return $tb <=> $ta;
  146. });
  147. $s[$k] = array_slice($s[$k], 0, SD_BAN_MAX, true);
  148. }
  149. }
  150. ​
  151. function sd_threat(string $reason): bool
  152. {
  153. if (sd_shield_exempt()) return false;
  154. $points = SD_THREATS[$reason] ?? 10;
  155. $key = sd_ban_key(sd_ip());
  156. $result = sd_state('bans', static function (array &$s) use ($key, $reason, $points): ?array {
  157. $now = time();
  158. sd_shield_prune($s, $now);
  159. if (isset($s['bans'][$key])) return null;
  160. $events = $s['score'][$key] ?? [];
  161. $events[] = [$now, $points, $reason];
  162. $events = array_slice($events, -40);
  163. $total = array_sum(array_map(static fn($e) => (int)$e[1], $events));
  164. if ($total < SD_BAN_THRESHOLD) {
  165. $s['score'][$key] = $events;
  166. return null;
  167. }
  168. unset($s['score'][$key]);
  169. $level = (int)($s['level'][$key][0] ?? 0) + 1;
  170. $s['level'][$key] = [$level, $now];
  171. $until = $now + SD_BAN_STEPS[min($level, count(SD_BAN_STEPS)) - 1];
  172. $s['bans'][$key] = ['at' => $now, 'until' => $until, 'reason' => $reason, 'auto' => 1, 'note' => '', 'last' => $now, 'level' => $level];
  173. $mail = array_values(array_filter((array)($s['mail'] ?? []), static fn($t) => $now - (int)$t < 3600));
  174. $send = count($mail) < SD_BAN_MAIL_LIMIT;
  175. if ($send) $mail[] = $now;
  176. $s['mail'] = $mail;
  177. return ['until' => $until, 'level' => $level, 'mail' => $send];
  178. });
  179. if (!str_starts_with($reason, 'fail_')) sd_audit('threat', ['reason' => $reason, 'points' => $points]);
  180. if ($result === null) return false;
  181. sd_audit('ip_banned', ['ban' => $key, 'reason' => $reason, 'until' => gmdate('c', $result['until']), 'level' => $result['level']]);
  182. if ($result['mail']) {
  183. sd_alert('alert_ban_subject', 'alert_ban_body', ['ban' => $key, 'reason' => t('shield_r_' . $reason), 'until' => gmdate('Y-m-d H:i', $result['until']) . ' UTC']);
  184. }
  185. return true;
  186. }
  187. ​
  188. function sd_shield_trust(): void
  189. {
  190. $key = sd_ban_key(sd_ip());
  191. sd_state('bans', static function (array &$s) use ($key): void {
  192. $now = time();
  193. sd_shield_prune($s, $now);
  194. $s['trust'][$key] = $now + SD_TRUST_TTL;
  195. unset($s['score'][$key]);
  196. });
  197. }
  198. ​
  199. function sd_shield_gate(): void
  200. {
  201. $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
  202. $state = sd_read_state('bans');
  203. if (!sd_shield_allowed($state)) {
  204. $ban = sd_shield_match($state['bans'] ?? [], sd_ip());
  205. if ($ban !== null) {
  206. $now = time();
  207. if ($now - (int)($ban[1]['last'] ?? 0) >= 60) {
  208. sd_state('bans', static function (array &$s) use ($ban, $now): void {
  209. if (isset($s['bans'][$ban[0]])) $s['bans'][$ban[0]]['last'] = $now;
  210. });
  211. }
  212. sd_shield_deny(403, t('shield_blocked'));
  213. }
  214. if (preg_match(SD_SCANNER_UA, sd_ua())) {
  215. sd_threat('scanner');
  216. sd_shield_deny(403);
  217. }
  218. if (!in_array($method, ['GET', 'POST', 'HEAD'], true)) {
  219. sd_threat('method');
  220. sd_shield_deny(405);
  221. }
  222. if (sd_host() === '') {
  223. sd_threat('host');
  224. sd_shield_deny(400);
  225. }
  226. } elseif (sd_host() === '') {
  227. sd_shield_deny(400);
  228. }
  229. $probe = isset($_SERVER['SD_PROBE']) || isset($_SERVER['REDIRECT_SD_PROBE']);
  230. if (!$probe && sd_request_path_ok()) return;
  231. $uri = sd_shield_text((string)($_SERVER['REQUEST_URI'] ?? ''));
  232. $attack = sd_shield_signature($uri);
  233. if ($probe || $attack) {
  234. sd_threat($attack ? 'signature' : 'probe');
  235. sd_shield_deny(404);
  236. }
  237. if ($method !== 'GET') sd_shield_deny(404);
  238. header('Location: ' . sd_url_base(), true, 303);
  239. exit;
  240. }
  241. ​
  242. function sd_shield_inspect(): void
  243. {
  244. if (($_SESSION['stage'] ?? '') === 'full') return;
  245. $text = (string)($_SERVER['QUERY_STRING'] ?? '');
  246. foreach ($_POST as $name => $value) {
  247. if (in_array($name, ['password', 'password2', 'current', '_pow', '_nonce', '_csrf'], true)) continue;
  248. $text .= "\n" . (is_string($value) ? $value : (string)json_encode($value));
  249. }
  250. if (sd_shield_signature(sd_shield_text($text))) {
  251. sd_threat('signature');
  252. sd_shield_deny(403);
  253. }
  254. }
  255. ​
  256. function sd_shield_ban(string $address, int $duration, string $note): ?string
  257. {
  258. $range = sd_cidr_parse($address);
  259. if ($range === null) return 'err_ban_invalid';
  260. if ($range['bits'] < ($range['max'] === 32 ? 16 : 32)) return 'err_ban_range';
  261. if (sd_cidr_contains($range['text'], sd_ip())) return 'err_ban_self';
  262. if (!in_array($duration, SD_BAN_DURATIONS, true)) $duration = 86400;
  263. $note = mb_substr(trim((string)preg_replace('/[\x00-\x1F\x7F]/u', ' ', $note)), 0, 120);
  264. $key = $range['text'];
  265. sd_state('bans', static function (array &$s) use ($key, $duration, $note): void {
  266. $now = time();
  267. sd_shield_prune($s, $now);
  268. $s['bans'][$key] = ['at' => $now, 'until' => $duration === 0 ? 0 : $now + $duration, 'reason' => 'manual', 'auto' => 0, 'note' => $note, 'last' => 0, 'level' => 0];
  269. unset($s['score'][$key]);
  270. });
  271. sd_audit('ip_banned', ['ban' => $key, 'reason' => 'manual', 'until' => $duration === 0 ? 'permanent' : gmdate('c', time() + $duration)]);
  272. return null;
  273. }
  274. ​
  275. function sd_shield_unban(string $key): bool
  276. {
  277. $done = sd_state('bans', static function (array &$s) use ($key): bool {
  278. sd_shield_prune($s, time());
  279. $found = isset($s['bans'][$key]);
  280. unset($s['bans'][$key], $s['score'][$key], $s['level'][$key]);
  281. return $found;
  282. });
  283. if ($done) sd_audit('ip_unbanned', ['ban' => $key]);
  284. return $done;
  285. }
  286. ​
  287. function sd_shield_allow(string $address, string $note): ?string
  288. {
  289. $range = sd_cidr_parse($address);
  290. if ($range === null) return 'err_ban_invalid';
  291. if ($range['bits'] < ($range['max'] === 32 ? 16 : 32)) return 'err_ban_range';
  292. $key = $range['text'];
  293. $note = mb_substr(trim((string)preg_replace('/[\x00-\x1F\x7F]/u', ' ', $note)), 0, 120);
  294. $ok = sd_state('bans', static function (array &$s) use ($key, $note): bool {
  295. sd_shield_prune($s, time());
  296. if (!isset($s['allow'][$key]) && count($s['allow']) >= SD_ALLOW_MAX) return false;
  297. $s['allow'][$key] = ['at' => time(), 'note' => $note];
  298. return true;
  299. });
  300. if (!$ok) return 'err_allow_full';
  301. sd_audit('allow_added', ['ip' => $key]);
  302. return null;
  303. }
  304. ​
  305. function sd_shield_disallow(string $key): void
  306. {
  307. sd_state('bans', static function (array &$s) use ($key): void {
  308. unset($s['allow'][$key]);
  309. });
  310. sd_audit('allow_removed', ['ip' => $key]);
  311. }
  312. ​
  313. function sd_route_shield(): never
  314. {
  315. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  316. if (!sd_csrf_ok()) sd_redirect('shield');
  317. $error = null;
  318. $message = '';
  319. switch ((string)($_POST['action'] ?? '')) {
  320. case 'ban':
  321. $error = sd_shield_ban((string)($_POST['address'] ?? ''), (int)($_POST['duration'] ?? 86400), (string)($_POST['note'] ?? ''));
  322. $message = 'msg_banned';
  323. break;
  324. case 'unban':
  325. sd_shield_unban((string)($_POST['key'] ?? ''));
  326. $message = 'msg_unbanned';
  327. break;
  328. case 'allow':
  329. $error = sd_shield_allow((string)($_POST['address'] ?? ''), (string)($_POST['note'] ?? ''));
  330. $message = 'msg_allowed';
  331. break;
  332. case 'allow_me':
  333. $error = sd_shield_allow(sd_ip(), t('shield_me_note'));
  334. $message = 'msg_allowed';
  335. break;
  336. case 'disallow':
  337. sd_shield_disallow((string)($_POST['key'] ?? ''));
  338. $message = 'msg_allow_removed';
  339. break;
  340. }
  341. if ($message !== '') sd_flash($error === null ? 'ok' : 'error', t($error ?? $message));
  342. sd_redirect('shield');
  343. }
  344. sd_view_shield();
  345. exit;
  346. }
  347. ​
  348. function sd_shield_time(int $ts): string
  349. {
  350. return '<time class="sd-time" datetime="' . h(gmdate('c', $ts)) . '">' . h(gmdate('Y-m-d H:i', $ts)) . ' UTC</time>';
  351. }
  352. ​
  353. function sd_shield_form(string $action, string $key, string $label, string $tone, string $icon, string $confirm = ''): string
  354. {
  355. return '<form method="post" action="' . h(sd_url('shield')) . '"' . ($confirm !== '' ? ' data-confirm="' . h($confirm) . '"' : '') . '>' . sd_csrf_field()
  356. . '<input type="hidden" name="action" value="' . h($action) . '"><input type="hidden" name="key" value="' . h($key) . '">'
  357. . '<button type="submit" class="orb-btn orb-btn-outline orb-btn-sm orb-tone-' . h($tone) . '">' . sd_icon($icon, 14) . h($label) . '</button></form>';
  358. }
  359. ​
  360. function sd_view_shield(): void
  361. {
  362. sd_open(t('shield_title'), 'app', 'shield');
  363. $state = sd_read_state('bans');
  364. $now = time();
  365. $bans = array_filter((array)($state['bans'] ?? []), static fn($b) => is_array($b) && sd_shield_active($b, $now));
  366. uasort($bans, static fn($a, $b) => (int)($b['at'] ?? 0) <=> (int)($a['at'] ?? 0));
  367. $allow = (array)($state['allow'] ?? []);
  368. $watch = [];
  369. foreach ((array)($state['score'] ?? []) as $key => $events) {
  370. $events = array_values(array_filter((array)$events, static fn($e) => is_array($e) && $now - (int)($e[0] ?? 0) < SD_BAN_WINDOW));
  371. if (!$events || isset($bans[$key])) continue;
  372. $last = end($events);
  373. $watch[$key] = ['points' => array_sum(array_map(static fn($e) => (int)$e[1], $events)), 'reason' => (string)$last[2], 't' => (int)$last[0]];
  374. }
  375. uasort($watch, static fn($a, $b) => $b['points'] <=> $a['points']);
  376. $watch = array_slice($watch, 0, 50, true);
  377. $ip = sd_ip();
  378. $status = sd_shield_allowed($state) ? ['success', t('shield_allowed')] : (sd_shield_trusted($state) ? ['success', t('shield_trusted')] : ['neutral', t('shield_untrusted')]);
  379. $auto = count(array_filter($bans, static fn($b) => !empty($b['auto'])));
  380. ​
  381. echo '<p class="orb-lead orb-mb-4 sd-shield-lead">' . h(t('shield_lead')) . '</p>';
  382. echo '<div class="sd-stats sd-shield-stats orb-mb-4">'
  383. . '<div class="sd-stat"><span class="sd-stat-n orb-mono sd-shield-ip">' . h($ip) . '</span><span class="sd-stat-l">' . h(t('shield_your_ip')) . ' <span class="orb-pill orb-pill-' . $status[0] . '">' . h($status[1]) . '</span></span></div>'
  384. . '<div class="sd-stat"><span class="sd-stat-n">' . count($bans) . '</span><span class="sd-stat-l">' . h(t('shield_active')) . '</span><span class="orb-text-xs orb-text-muted">' . h(t('shield_active_split', ['a' => (string)$auto, 'm' => (string)(count($bans) - $auto)])) . '</span></div>'
  385. . '<div class="sd-stat"><span class="sd-stat-n">' . count($watch) . '</span><span class="sd-stat-l">' . h(t('shield_watched')) . '</span></div>'
  386. . '<div class="sd-stat"><span class="sd-stat-n">' . count($allow) . '</span><span class="sd-stat-l">' . h(t('shield_allow_title')) . '</span></div></div>';
  387. ​
  388. echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('lock', 16) . ' ' . h(t('shield_bans')) . '</span><span class="orb-badge">' . count($bans) . '</span></div>';
  389. if (!$bans) {
  390. echo '<div class="orb-card-body orb-text-muted">' . h(t('shield_no_bans')) . '</div>';
  391. } else {
  392. echo '<div class="orb-table-wrap"><table class="orb-table orb-table-responsive"><thead><tr><th>' . h(t('col_address')) . '</th><th>' . h(t('col_reason')) . '</th><th>' . h(t('col_since')) . '</th><th>' . h(t('col_until')) . '</th><th>' . h(t('col_last')) . '</th><th></th></tr></thead><tbody>';
  393. foreach ($bans as $key => $b) {
  394. $reason = t('shield_r_' . (string)($b['reason'] ?? 'manual'));
  395. $note = trim((string)($b['note'] ?? ''));
  396. $until = (int)($b['until'] ?? 0);
  397. echo '<tr><td data-label="' . h(t('col_address')) . '" class="orb-mono">' . h((string)$key) . '</td>'
  398. . '<td data-label="' . h(t('col_reason')) . '"><div class="sd-reason"><span class="orb-pill orb-pill-' . (!empty($b['auto']) ? 'danger' : 'warning') . '">' . h(!empty($b['auto']) ? t('shield_auto') : t('shield_manual')) . '</span><span>' . h($reason) . '</span>' . ($note !== '' ? '<span class="orb-text-xs orb-text-muted">' . h($note) . '</span>' : '') . '</div></td>'
  399. . '<td data-label="' . h(t('col_since')) . '">' . sd_shield_time((int)($b['at'] ?? 0)) . '</td>'
  400. . '<td data-label="' . h(t('col_until')) . '">' . ($until === 0 ? h(t('shield_forever')) : sd_shield_time($until)) . '</td>'
  401. . '<td data-label="' . h(t('col_last')) . '">' . ((int)($b['last'] ?? 0) > 0 ? sd_shield_time((int)$b['last']) : '—') . '</td>'
  402. . '<td>' . sd_shield_form('unban', (string)$key, t('shield_unban'), 'success', 'undo', t('shield_unban_confirm')) . '</td></tr>';
  403. }
  404. echo '</tbody></table></div>';
  405. }
  406. echo '</section>';
  407. ​
  408. $durations = '';
  409. foreach (SD_BAN_DURATIONS as $d) $durations .= '<option value="' . $d . '"' . ($d === 86400 ? ' selected' : '') . '>' . h(t('shield_d_' . $d)) . '</option>';
  410. echo '<div class="orb-grid orb-grid-2 orb-gap-4 orb-mt-4 sd-settings">';
  411. echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('plus', 16) . ' ' . h(t('shield_ban_title')) . '</span></div><div class="orb-card-body"><form method="post" action="' . h(sd_url('shield')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="ban">'
  412. . sd_field('address', t('shield_address'), 'text', ['id' => 'f-ban-address', 'required' => true, 'maxlength' => 64, 'class' => 'orb-mono', 'spellcheck' => 'false', 'autocomplete' => 'off', 'placeholder' => '203.0.113.7'], t('shield_address_hint'))
  413. . '<div class="orb-form-group"><label class="orb-label" for="f-duration">' . h(t('shield_duration')) . '</label><select class="orb-select" id="f-duration" name="duration">' . $durations . '</select></div>'
  414. . sd_field('note', t('shield_note'), 'text', ['id' => 'f-ban-note', 'maxlength' => 120, 'autocomplete' => 'off'])
  415. . '<button class="orb-btn orb-btn-primary" type="submit">' . sd_icon('lock') . h(t('shield_ban_btn')) . '</button></form></div></section>';
  416. ​
  417. echo '<section class="orb-card"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('check', 16) . ' ' . h(t('shield_allow_title')) . '</span></div><div class="orb-card-body">'
  418. . '<p class="orb-text-sm orb-text-muted orb-mb-4">' . h(t('shield_allow_hint')) . '</p>';
  419. if ($allow) {
  420. echo '<ul class="sd-allow">';
  421. foreach ($allow as $key => $a) {
  422. $note = trim((string)($a['note'] ?? ''));
  423. echo '<li><div><span class="orb-mono">' . h((string)$key) . '</span>' . ($note !== '' ? '<div class="orb-text-xs orb-text-muted">' . h($note) . '</div>' : '') . '</div>' . sd_shield_form('disallow', (string)$key, t('shield_remove'), 'danger', 'trash') . '</li>';
  424. }
  425. echo '</ul>';
  426. } else {
  427. echo '<p class="orb-text-sm orb-text-muted orb-mb-4">' . h(t('shield_allow_empty')) . '</p>';
  428. }
  429. echo '<form method="post" action="' . h(sd_url('shield')) . '" class="orb-mt-4">' . sd_csrf_field() . '<input type="hidden" name="action" value="allow">'
  430. . sd_field('address', t('shield_address'), 'text', ['required' => true, 'maxlength' => 64, 'class' => 'orb-mono', 'spellcheck' => 'false', 'autocomplete' => 'off', 'id' => 'f-allow-address', 'placeholder' => $ip])
  431. . sd_field('note', t('shield_note'), 'text', ['id' => 'f-allow-note', 'maxlength' => 120, 'autocomplete' => 'off'])
  432. . '<div class="orb-flex orb-gap-2 orb-flex-wrap"><button class="orb-btn orb-btn-primary" type="submit">' . sd_icon('plus') . h(t('shield_allow_btn')) . '</button>'
  433. . '<button class="orb-btn orb-btn-secondary" type="submit" name="action" value="allow_me" formnovalidate>' . sd_icon('shield') . h(t('shield_allow_me')) . '</button></div></form>'
  434. . '</div></section></div>';
  435. ​
  436. echo '<section class="orb-card orb-mt-4"><div class="orb-card-header"><span class="orb-card-title">' . sd_icon('alert', 16) . ' ' . h(t('shield_watch_title')) . '</span><span class="orb-badge">' . count($watch) . '</span></div>';
  437. if (!$watch) {
  438. echo '<div class="orb-card-body orb-text-muted">' . h(t('shield_watch_empty')) . '</div>';
  439. } else {
  440. echo '<div class="orb-table-wrap"><table class="orb-table orb-table-responsive"><thead><tr><th>' . h(t('col_address')) . '</th><th>' . h(t('col_points')) . '</th><th>' . h(t('col_reason')) . '</th><th>' . h(t('col_last')) . '</th><th></th></tr></thead><tbody>';
  441. foreach ($watch as $key => $w) {
  442. $pct = min(100, (int)round($w['points'] * 100 / SD_BAN_THRESHOLD));
  443. echo '<tr><td data-label="' . h(t('col_address')) . '" class="orb-mono">' . h((string)$key) . '</td>'
  444. . '<td data-label="' . h(t('col_points')) . '"><progress class="sd-meter" max="100" value="' . $pct . '"></progress><span class="orb-text-xs orb-text-muted">' . h(t('shield_points', ['n' => (string)$w['points']])) . '</span></td>'
  445. . '<td data-label="' . h(t('col_reason')) . '">' . h(t('shield_r_' . $w['reason'])) . '</td>'
  446. . '<td data-label="' . h(t('col_last')) . '">' . sd_shield_time($w['t']) . '</td>'
  447. . '<td><form method="post" action="' . h(sd_url('shield')) . '" data-confirm="' . h(t('shield_ban_confirm')) . '">' . sd_csrf_field() . '<input type="hidden" name="action" value="ban"><input type="hidden" name="address" value="' . h((string)$key) . '"><input type="hidden" name="duration" value="2592000"><button type="submit" class="orb-btn orb-btn-outline orb-btn-sm orb-tone-danger">' . sd_icon('lock', 14) . h(t('shield_ban_btn')) . '</button></form></td></tr>';
  448. }
  449. echo '</tbody></table></div>';
  450. }
  451. echo '</section>';
  452. sd_close('app');
  453. }
  454. ​