WebOrbiton
v1.3.0

AtheraStudio SyncDrop

461 lines · 18.2 KB
  1. <?php
  2. declare(strict_types=1);
  3. ​
  4. defined('SD_BOOT') || exit;
  5. ​
  6. const SD_SETUP_TTL = 3600;
  7. const SD_STAGE_TTL = 300;
  8. ​
  9. function sd_setup_code(): string
  10. {
  11. $file = sd_path('setup');
  12. $data = is_file($file) ? json_decode((string)@file_get_contents($file), true) : null;
  13. if (is_array($data) && isset($data['code'], $data['exp']) && (int)$data['exp'] > time()) return (string)$data['code'];
  14. $raw = sd_b32_encode(random_bytes(10));
  15. $code = implode('-', str_split(substr($raw, 0, 16), 4));
  16. $content = json_encode(['code' => $code, 'exp' => time() + SD_SETUP_TTL, 'instance' => sd_instance(), 'info' => 'AtheraStudio SyncDrop setup code']);
  17. @file_put_contents($file, $content . "\n", LOCK_EX);
  18. @chmod($file, 0600);
  19. return $code;
  20. }
  21. ​
  22. function sd_norm_code(string $code): string
  23. {
  24. return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code));
  25. }
  26. ​
  27. function sd_default_target(): ?string
  28. {
  29. $doc = sd_docroot();
  30. $candidates = sd_app() !== $doc ? [$doc] : [];
  31. for ($dir = dirname($doc); dirname($dir) !== $dir; $dir = dirname($dir)) {
  32. if (sd_webroot_named($dir, SD_WEBROOTS)) {
  33. $candidates[] = $dir;
  34. break;
  35. }
  36. }
  37. foreach ($candidates as $dir) {
  38. $path = sd_target_path_check($dir, $err);
  39. if ($path !== null) return $path;
  40. }
  41. return null;
  42. }
  43. ​
  44. function sd_route_setup(): never
  45. {
  46. sd_session_start();
  47. $code = sd_setup_code();
  48. $errors = [];
  49. $old = [];
  50. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  51. $old = ['user' => (string)($_POST['user'] ?? ''), 'email' => (string)($_POST['email'] ?? ''), 'lang' => (string)($_POST['lang'] ?? '')];
  52. if (sd_locked() > 0) {
  53. $errors[] = 'err_locked_short';
  54. } elseif (!sd_csrf_ok() || !sd_honeypot_ok() || !sd_pow_verify('setup')) {
  55. $errors[] = 'err_antibot';
  56. } elseif (!hash_equals(sd_norm_code($code), sd_norm_code((string)($_POST['code'] ?? '')))) {
  57. sd_fail('setup_code');
  58. $errors[] = 'err_setup_code';
  59. } else {
  60. $user = trim($old['user']);
  61. $email = trim($old['email']);
  62. $password = (string)($_POST['password'] ?? '');
  63. $lang = isset(SD_LANGS[$old['lang']]) ? $old['lang'] : sd_lang();
  64. if (!preg_match('/^[A-Za-z0-9._-]{3,32}$/', $user)) $errors[] = 'err_username';
  65. if (!filter_var($email, FILTER_VALIDATE_EMAIL) || preg_match('/[\r\n]/', $email)) $errors[] = 'err_email';
  66. if (!sd_password_strong($password, $user)) $errors[] = 'err_password_weak';
  67. if (!hash_equals($password, (string)($_POST['password2'] ?? ''))) $errors[] = 'err_password_match';
  68. if (!$errors) {
  69. $targets = [];
  70. $main = sd_default_target();
  71. if ($main !== null) {
  72. $targets['main'] = ['name' => basename(dirname($main)) ?: basename($main), 'path' => $main, 'exclude' => SD_DEFAULT_EXCLUDES];
  73. }
  74. sd_env_save([
  75. 'SD_USER' => $user,
  76. 'SD_PASS' => sd_password_hash($password),
  77. 'SD_EMAIL' => $email,
  78. 'SD_ALERTS' => 1,
  79. 'SD_LANG' => $lang,
  80. 'SD_SECRET' => bin2hex(random_bytes(32)),
  81. 'SD_EPOCH' => 1,
  82. 'SD_TOTP' => '',
  83. 'SD_TOTP_STEP' => 0,
  84. 'SD_RECOVERY' => [],
  85. 'SD_TARGETS' => $targets,
  86. 'SD_CREATED' => gmdate('c'),
  87. 'SD_INSTANCE' => sd_instance(),
  88. ]);
  89. @unlink(sd_path('setup'));
  90. sd_set_lang($lang);
  91. sd_session_elevate('enroll');
  92. $_SESSION['stage_at'] = time();
  93. sd_audit('setup_done', ['user' => $user]);
  94. sd_redirect('enroll');
  95. }
  96. }
  97. }
  98. sd_view_setup($errors, $old);
  99. exit;
  100. }
  101. ​
  102. function sd_route_login(): never
  103. {
  104. $error = '';
  105. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  106. $locked = sd_locked();
  107. if ($locked > 0) {
  108. sd_view_login('', $locked);
  109. exit;
  110. }
  111. if (!sd_csrf_ok() || !sd_honeypot_ok() || !sd_pow_verify('login')) {
  112. sd_fail('antibot');
  113. $error = 'err_antibot';
  114. } else {
  115. $env = sd_env();
  116. $user = (string)($_POST['user'] ?? '');
  117. $password = (string)($_POST['password'] ?? '');
  118. $userOk = hash_equals((string)$env['SD_USER'], $user);
  119. $passOk = password_verify($password, (string)$env['SD_PASS']);
  120. if ($userOk && $passOk) {
  121. if (password_needs_rehash((string)$env['SD_PASS'], defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)) {
  122. sd_env_set(['SD_PASS' => sd_password_hash($password)]);
  123. }
  124. $next = !empty($env['SD_TOTP']) ? '2fa' : 'enroll';
  125. sd_session_elevate($next);
  126. $_SESSION['stage_at'] = time();
  127. $_SESSION['tries'] = 0;
  128. sd_audit('password_ok');
  129. sd_redirect($next);
  130. }
  131. sd_fail('password');
  132. $error = 'err_login';
  133. }
  134. }
  135. sd_view_login($error, sd_locked());
  136. exit;
  137. }
  138. ​
  139. function sd_remember_host(): void
  140. {
  141. $host = sd_host();
  142. if ($host !== '' && (string)(sd_env()['SD_HOST'] ?? '') !== $host) sd_env_set(['SD_HOST' => $host]);
  143. }
  144. ​
  145. function sd_stage_fresh(): bool
  146. {
  147. return time() - (int)($_SESSION['stage_at'] ?? 0) <= SD_STAGE_TTL;
  148. }
  149. ​
  150. function sd_complete_login(string $method): never
  151. {
  152. sd_session_elevate('full');
  153. sd_fail_reset();
  154. sd_shield_trust();
  155. sd_remember_host();
  156. sd_audit('login_ok', ['method' => $method]);
  157. sd_alert('alert_login_subject', 'alert_login_body', ['method' => t('method_' . $method)]);
  158. sd_redirect('sync');
  159. }
  160. ​
  161. function sd_route_twofa(): never
  162. {
  163. if (!sd_stage_fresh()) {
  164. sd_session_reset();
  165. sd_flash('error', t('err_stage_expired'));
  166. sd_redirect('login');
  167. }
  168. $error = '';
  169. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  170. if (!sd_csrf_ok()) sd_redirect('login');
  171. $env = sd_env();
  172. $input = trim((string)($_POST['code'] ?? ''));
  173. if (preg_match('/^\d[\d ]{4,6}\d$/', $input)) {
  174. $step = sd_totp_match((string)$env['SD_TOTP'], $input, (int)($env['SD_TOTP_STEP'] ?? 0));
  175. if ($step !== null) {
  176. sd_env_set(['SD_TOTP_STEP' => $step]);
  177. sd_complete_login('totp');
  178. }
  179. } else {
  180. $norm = sd_recovery_normalize($input);
  181. $codes = (array)($env['SD_RECOVERY'] ?? []);
  182. foreach ($codes as $i => $hash) {
  183. if (strlen($norm) === 12 && password_verify($norm, (string)$hash)) {
  184. unset($codes[$i]);
  185. sd_env_set(['SD_RECOVERY' => array_values($codes)]);
  186. sd_audit('recovery_used', ['left' => count($codes)]);
  187. sd_alert('alert_recovery_subject', 'alert_recovery_body', ['left' => (string)count($codes)]);
  188. sd_complete_login('recovery');
  189. }
  190. }
  191. }
  192. $_SESSION['tries'] = (int)($_SESSION['tries'] ?? 0) + 1;
  193. sd_fail('totp');
  194. if ($_SESSION['tries'] >= 5) {
  195. sd_session_reset();
  196. sd_flash('error', t('err_too_many'));
  197. sd_redirect('login');
  198. }
  199. $error = 'err_code';
  200. }
  201. sd_view_twofa($error);
  202. exit;
  203. }
  204. ​
  205. function sd_store_recovery(): array
  206. {
  207. $codes = sd_recovery_codes();
  208. $hashes = array_map(static fn($c) => password_hash(sd_recovery_normalize($c), PASSWORD_BCRYPT, ['cost' => 10]), $codes);
  209. sd_env_set(['SD_RECOVERY' => $hashes]);
  210. return $codes;
  211. }
  212. ​
  213. function sd_route_enroll(): never
  214. {
  215. if (!sd_stage_fresh() && empty($_SESSION['enroll_secret'])) {
  216. sd_session_reset();
  217. sd_redirect('login');
  218. }
  219. $env = sd_env();
  220. if (!empty($env['SD_TOTP'])) {
  221. sd_session_reset();
  222. sd_redirect('login');
  223. }
  224. if (empty($_SESSION['enroll_secret'])) $_SESSION['enroll_secret'] = sd_totp_secret();
  225. $secret = (string)$_SESSION['enroll_secret'];
  226. $error = '';
  227. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  228. if (!sd_csrf_ok()) sd_redirect('login');
  229. $step = sd_totp_match($secret, (string)($_POST['code'] ?? ''));
  230. if ($step !== null) {
  231. sd_env_set(['SD_TOTP' => $secret, 'SD_TOTP_STEP' => $step]);
  232. $codes = sd_store_recovery();
  233. sd_session_elevate('full');
  234. $_SESSION['recovery_show'] = $codes;
  235. sd_fail_reset();
  236. sd_shield_trust();
  237. sd_remember_host();
  238. sd_audit('totp_enrolled');
  239. sd_alert('alert_2fa_subject', 'alert_2fa_body');
  240. sd_redirect('recovery');
  241. }
  242. $error = 'err_code';
  243. }
  244. sd_view_enroll($secret, sd_totp_uri($secret, (string)$env['SD_USER']), $error);
  245. exit;
  246. }
  247. ​
  248. function sd_route_recovery(): never
  249. {
  250. $codes = $_SESSION['recovery_show'] ?? null;
  251. unset($_SESSION['recovery_show']);
  252. if (!is_array($codes) || !$codes) sd_redirect('sync');
  253. sd_view_recovery($codes, !empty($_SESSION['recovery_app']));
  254. unset($_SESSION['recovery_app']);
  255. exit;
  256. }
  257. ​
  258. function sd_route_history(): never
  259. {
  260. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  261. if (!sd_csrf_ok()) sd_redirect('history');
  262. if (($_POST['action'] ?? '') === 'rollback') {
  263. $result = sd_with_lock(static fn(): array => sd_rollback((string)($_POST['id'] ?? '')));
  264. if (isset($result['ok'])) {
  265. sd_flash($result['skipped'] ? 'warn' : 'ok', t($result['skipped'] ? 'msg_rollback_partial' : 'msg_rollback_ok', ['n' => (string)$result['restored'], 's' => (string)count($result['skipped'])]));
  266. } else {
  267. sd_flash('error', t('err_rollback'));
  268. }
  269. }
  270. sd_redirect('history');
  271. }
  272. sd_view_history();
  273. exit;
  274. }
  275. ​
  276. function sd_verify_current_password(): bool
  277. {
  278. $ok = password_verify((string)($_POST['current'] ?? ''), (string)(sd_env()['SD_PASS'] ?? ''));
  279. if (!$ok) sd_fail('settings_password');
  280. return $ok;
  281. }
  282. ​
  283. function sd_route_settings(): never
  284. {
  285. $errors = [];
  286. if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  287. if (!sd_csrf_ok()) sd_redirect('settings');
  288. $env = sd_env();
  289. $action = (string)($_POST['action'] ?? '');
  290. switch ($action) {
  291. case 'alerts':
  292. $email = trim((string)($_POST['email'] ?? ''));
  293. if (!sd_verify_current_password()) $errors[] = 'err_password_current';
  294. elseif (!filter_var($email, FILTER_VALIDATE_EMAIL) || preg_match('/[\r\n]/', $email)) $errors[] = 'err_email';
  295. else {
  296. $old = (string)$env['SD_EMAIL'];
  297. sd_env_set(['SD_EMAIL' => $email, 'SD_ALERTS' => !empty($_POST['alerts']) ? 1 : 0]);
  298. sd_audit('alerts_changed');
  299. if ($old !== $email) sd_alert('alert_email_subject', 'alert_email_body', ['email' => $email], $old);
  300. if (!empty($_POST['test'])) {
  301. $sent = sd_alert('alert_test_subject', 'alert_test_body');
  302. sd_flash($sent ? 'ok' : 'warn', t($sent ? 'msg_test_sent' : 'msg_test_failed'));
  303. } else {
  304. sd_flash('ok', t('msg_saved'));
  305. }
  306. sd_redirect('settings');
  307. }
  308. break;
  309. case 'password':
  310. $new = (string)($_POST['password'] ?? '');
  311. if (!sd_verify_current_password()) $errors[] = 'err_password_current';
  312. elseif (!sd_password_strong($new, (string)$env['SD_USER'])) $errors[] = 'err_password_weak';
  313. elseif (!hash_equals($new, (string)($_POST['password2'] ?? ''))) $errors[] = 'err_password_match';
  314. else {
  315. sd_env_set(['SD_PASS' => sd_password_hash($new), 'SD_EPOCH' => (int)$env['SD_EPOCH'] + 1]);
  316. sd_session_elevate('full');
  317. sd_audit('password_changed');
  318. sd_alert('alert_password_subject', 'alert_password_body');
  319. sd_flash('ok', t('msg_password_changed'));
  320. sd_redirect('settings');
  321. }
  322. break;
  323. case 'recovery':
  324. $step = sd_totp_match((string)$env['SD_TOTP'], (string)($_POST['code'] ?? ''), (int)($env['SD_TOTP_STEP'] ?? 0));
  325. if ($step === null) {
  326. sd_fail('settings_totp');
  327. $errors[] = 'err_code';
  328. } else {
  329. sd_env_set(['SD_TOTP_STEP' => $step]);
  330. $_SESSION['recovery_show'] = sd_store_recovery();
  331. $_SESSION['recovery_app'] = true;
  332. sd_audit('recovery_regenerated');
  333. sd_alert('alert_recovery_new_subject', 'alert_recovery_new_body');
  334. sd_redirect('recovery');
  335. }
  336. break;
  337. case 'sessions':
  338. sd_env_set(['SD_EPOCH' => (int)$env['SD_EPOCH'] + 1]);
  339. sd_session_elevate('full');
  340. sd_audit('sessions_revoked');
  341. sd_flash('ok', t('msg_sessions'));
  342. sd_redirect('settings');
  343. case 'lang':
  344. $lang = (string)($_POST['lang'] ?? '');
  345. if (isset(SD_LANGS[$lang])) {
  346. sd_env_set(['SD_LANG' => $lang]);
  347. sd_set_lang($lang);
  348. }
  349. sd_redirect('settings');
  350. case 'target_add':
  351. $name = trim((string)($_POST['name'] ?? ''));
  352. $path = sd_target_path_check((string)($_POST['path'] ?? ''), $err);
  353. if ($name === '' || mb_strlen($name) > 60) $errors[] = 'err_target_name';
  354. elseif ($path === null) $errors[] = $err;
  355. else {
  356. $targets = sd_targets();
  357. foreach ($targets as $t) {
  358. if (realpath((string)$t['path']) === $path) {
  359. $errors[] = 'err_target_exists';
  360. break 2;
  361. }
  362. }
  363. $id = 't' . bin2hex(random_bytes(4));
  364. $targets[$id] = ['name' => $name, 'path' => $path, 'exclude' => SD_DEFAULT_EXCLUDES];
  365. sd_env_set(['SD_TARGETS' => $targets]);
  366. sd_audit('target_added', ['id' => $id, 'path' => $path]);
  367. sd_flash('ok', t('msg_saved'));
  368. sd_redirect('settings', ['t' => $id]);
  369. }
  370. break;
  371. case 'target_save':
  372. $id = (string)($_POST['id'] ?? '');
  373. $targets = sd_targets();
  374. if (!isset($targets[$id])) sd_redirect('settings');
  375. if (!empty($_POST['remove'])) {
  376. unset($targets[$id]);
  377. sd_env_set(['SD_TARGETS' => $targets]);
  378. sd_audit('target_removed', ['id' => $id]);
  379. sd_flash('ok', t('msg_saved'));
  380. sd_redirect('settings');
  381. }
  382. $name = trim((string)($_POST['name'] ?? ''));
  383. if ($name === '' || mb_strlen($name) > 60) {
  384. $errors[] = 'err_target_name';
  385. break;
  386. }
  387. $patterns = [];
  388. foreach (preg_split('/\R/', (string)($_POST['exclude'] ?? '')) as $line) {
  389. $line = trim($line);
  390. if ($line !== '' && mb_strlen($line) <= 200 && !preg_match('/[\x00-\x1F]/', $line)) $patterns[] = $line;
  391. }
  392. $targets[$id]['name'] = $name;
  393. $targets[$id]['exclude'] = array_values(array_unique(array_slice($patterns, 0, 200)));
  394. sd_env_set(['SD_TARGETS' => $targets]);
  395. sd_audit('target_saved', ['id' => $id]);
  396. sd_flash('ok', t('msg_saved'));
  397. sd_redirect('settings');
  398. }
  399. }
  400. sd_view_settings($errors);
  401. exit;
  402. }
  403. ​
  404. function sd_boot(): void
  405. {
  406. ini_set('display_errors', '0');
  407. ini_set('log_errors', '1');
  408. ini_set('expose_php', '0');
  409. sd_headers();
  410. if (sd_base() === null) sd_view_fatal(t('fatal_title'), t('fatal_base'));
  411. if (!is_dir(sd_data()) || !is_writable(sd_data())) sd_view_fatal(t('fatal_title'), t('fatal_data'));
  412. ini_set('error_log', sd_data('logs') . '/php-error.log');
  413. sd_shield_gate();
  414. if (!sd_https() && !sd_local()) {
  415. if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
  416. header('Location: https://' . sd_host() . (string)($_SERVER['REQUEST_URI'] ?? '/'), true, 301);
  417. exit;
  418. }
  419. http_response_code(403);
  420. exit;
  421. }
  422. if (!function_exists('hash_hmac') || !function_exists('random_bytes') || !function_exists('mb_strlen')) sd_view_fatal(t('fatal_title'), t('fatal_ext'));
  423. }
  424. ​
  425. function sd_run(): void
  426. {
  427. sd_boot();
  428. $api = $_GET['api'] ?? null;
  429. if (is_string($api)) sd_api($api);
  430. $page = is_string($_GET['p'] ?? null) ? $_GET['p'] : '';
  431. if (sd_env() === null) sd_route_setup();
  432. sd_session_start();
  433. if ($page === 'lang' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  434. if (sd_csrf_ok()) sd_set_lang((string)($_POST['lang'] ?? ''));
  435. sd_redirect(sd_stage() === 'full' ? 'settings' : 'login');
  436. }
  437. if ($page === 'logout' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
  438. if (sd_csrf_ok()) {
  439. if (sd_stage() === 'full') sd_audit('logout');
  440. session_destroy();
  441. setcookie(sd_cookie_name(), '', ['expires' => time() - 3600, 'path' => '/', 'secure' => sd_https(), 'httponly' => true, 'samesite' => 'Strict']);
  442. }
  443. sd_redirect('login');
  444. }
  445. $stage = sd_stage();
  446. if ($stage === '2fa') sd_route_twofa();
  447. if ($stage === 'enroll') sd_route_enroll();
  448. if ($stage !== 'full') sd_route_login();
  449. match ($page) {
  450. 'recovery' => sd_route_recovery(),
  451. 'history' => sd_route_history(),
  452. 'shield' => sd_route_shield(),
  453. 'update' => sd_route_update(),
  454. 'settings' => sd_route_settings(),
  455. default => (static function (): never {
  456. sd_view_sync();
  457. exit;
  458. })(),
  459. };
  460. }
  461. ​