WebOrbiton
v1.3.0

AtheraStudio SyncDrop

639 lines · 23.0 KB
  1. <?php
  2. declare(strict_types=1);
  3. ​
  4. defined('SD_BOOT') || exit;
  5. ​
  6. const SD_DEFAULT_EXCLUDES = ['node_modules/', '.DS_Store', 'Thumbs.db', 'desktop.ini', '*.log', '*.sqlite', '*.sqlite3', '*.swp', '.idea/', '.vscode/', '.htpasswd'];
  7. const SD_STAGING_TTL = 86400;
  8. const SD_VIEW_MAX = 1048576;
  9. ​
  10. function sd_targets(): array
  11. {
  12. $targets = sd_env()['SD_TARGETS'] ?? [];
  13. return is_array($targets) ? $targets : [];
  14. }
  15. ​
  16. function sd_target(string $id): ?array
  17. {
  18. $t = sd_targets()[$id] ?? null;
  19. if (!is_array($t)) return null;
  20. $root = realpath((string)($t['path'] ?? ''));
  21. if ($root === false || !is_dir($root) || !is_writable($root)) return null;
  22. $t['id'] = $id;
  23. $t['root'] = $root;
  24. $t['exclude'] = array_values(array_filter((array)($t['exclude'] ?? []), 'is_string'));
  25. $app = realpath(SD_ROOT);
  26. $t['app_rel'] = null;
  27. if ($app !== false && sd_inside($app, $root) && $app !== $root) {
  28. $t['app_rel'] = ltrim(str_replace('\\', '/', substr($app, strlen($root))), '/');
  29. }
  30. return $t;
  31. }
  32. ​
  33. function sd_target_path_check(string $path, ?string &$error): ?string
  34. {
  35. $error = null;
  36. $real = realpath(trim($path));
  37. if ($real === false || !is_dir($real)) {
  38. $error = 'err_target_missing';
  39. return null;
  40. }
  41. if (dirname($real) === $real) {
  42. $error = 'err_target_root';
  43. return null;
  44. }
  45. if (!is_writable($real)) {
  46. $error = 'err_target_readonly';
  47. return null;
  48. }
  49. $data = realpath(sd_data());
  50. $app = realpath(SD_ROOT);
  51. if (($data !== false && sd_inside($real, $data)) || ($app !== false && sd_inside($real, $app))) {
  52. $error = 'err_target_forbidden';
  53. return null;
  54. }
  55. return $real;
  56. }
  57. ​
  58. function sd_rel(mixed $path): ?string
  59. {
  60. if (!is_string($path) || $path === '' || strlen($path) > 1024 || str_contains($path, "\0")) return null;
  61. $path = str_replace('\\', '/', $path);
  62. if ($path[0] === '/' || preg_match('/^[A-Za-z]:/', $path) || !mb_check_encoding($path, 'UTF-8')) return null;
  63. $parts = explode('/', $path);
  64. foreach ($parts as $segment) {
  65. if ($segment === '' || $segment === '.' || $segment === '..' || strlen($segment) > 255 || preg_match('/[\x00-\x1F\x7F]/', $segment)) return null;
  66. }
  67. return implode('/', $parts);
  68. }
  69. ​
  70. function sd_always_excluded(string $rel, array $target): bool
  71. {
  72. $segments = explode('/', strtolower($rel));
  73. foreach ($segments as $segment) {
  74. if (in_array($segment, ['.git', '.svn', '.hg'], true) || str_starts_with($segment, '.syncdrop')) return true;
  75. }
  76. $name = (string)end($segments);
  77. if ($name === '.env' || str_starts_with($name, '.env.') || str_ends_with($name, '.env')) return true;
  78. if (str_starts_with($name, '.sd-') && str_ends_with($name, '.tmp')) return true;
  79. $app = $target['app_rel'] ?? null;
  80. if (is_string($app) && $app !== '' && ($rel === $app || str_starts_with($rel, $app . '/'))) return true;
  81. return false;
  82. }
  83. ​
  84. function sd_glob_regex(string $glob): string
  85. {
  86. $re = '';
  87. $n = strlen($glob);
  88. for ($i = 0; $i < $n; $i++) {
  89. $c = $glob[$i];
  90. if ($c === '*') {
  91. if ($i + 1 < $n && $glob[$i + 1] === '*') {
  92. $re .= '.*';
  93. $i++;
  94. } else {
  95. $re .= '[^/]*';
  96. }
  97. } elseif ($c === '?') {
  98. $re .= '[^/]';
  99. } else {
  100. $re .= preg_quote($c, '#');
  101. }
  102. }
  103. return '#^' . $re . '$#iu';
  104. }
  105. ​
  106. function sd_excluded(string $rel, array $target): bool
  107. {
  108. if (sd_always_excluded($rel, $target)) return true;
  109. $segments = explode('/', $rel);
  110. $dirs = array_slice($segments, 0, -1);
  111. foreach ($target['exclude'] as $pattern) {
  112. $pattern = trim($pattern);
  113. if ($pattern === '' || $pattern[0] === '#') continue;
  114. $dirOnly = str_ends_with($pattern, '/');
  115. $pattern = trim($pattern, '/');
  116. if ($pattern === '') continue;
  117. $anchored = str_contains($pattern, '/');
  118. $re = sd_glob_regex($pattern);
  119. if ($anchored) {
  120. $count = count($dirs);
  121. for ($k = 1; $k <= $count; $k++) {
  122. if (preg_match($re, implode('/', array_slice($dirs, 0, $k)))) return true;
  123. }
  124. if (!$dirOnly && preg_match($re, $rel)) return true;
  125. } else {
  126. foreach ($dirOnly ? $dirs : $segments as $segment) {
  127. if (preg_match($re, $segment)) return true;
  128. }
  129. }
  130. }
  131. return false;
  132. }
  133. ​
  134. function sd_dest(string $root, string $rel, bool $create, array &$createdDirs = []): ?string
  135. {
  136. $parts = explode('/', $rel);
  137. $file = array_pop($parts);
  138. $dir = $root;
  139. foreach ($parts as $part) {
  140. $dir .= DIRECTORY_SEPARATOR . $part;
  141. if (is_link($dir)) return null;
  142. if (is_dir($dir)) continue;
  143. if (file_exists($dir)) return null;
  144. if (!$create) continue;
  145. if (!@mkdir($dir, 0755)) return null;
  146. $createdDirs[] = $dir;
  147. }
  148. $full = $dir . DIRECTORY_SEPARATOR . $file;
  149. if (is_link($full) || is_dir($full)) return null;
  150. return $full;
  151. }
  152. ​
  153. function sd_cache_file(string $root): string
  154. {
  155. return sd_data('cache') . '/' . sha1($root) . '.json';
  156. }
  157. ​
  158. function sd_cache_load(string $root): array
  159. {
  160. $data = json_decode((string)@file_get_contents(sd_cache_file($root)), true);
  161. return is_array($data) ? $data : [];
  162. }
  163. ​
  164. function sd_cache_save(string $root, array $cache): void
  165. {
  166. $file = sd_cache_file($root);
  167. $tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
  168. if (@file_put_contents($tmp, json_encode($cache, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX) !== false) {
  169. @rename($tmp, $file) || @unlink($tmp);
  170. }
  171. }
  172. ​
  173. function sd_server_hash(string $full, string $rel, array &$cache, bool &$dirty): ?string
  174. {
  175. clearstatcache(true, $full);
  176. $st = @stat($full);
  177. if ($st === false) return null;
  178. $entry = $cache[$rel] ?? null;
  179. if (is_array($entry) && (int)$entry[0] === (int)$st['size'] && (int)$entry[1] === (int)$st['mtime']) return (string)$entry[2];
  180. $hash = @hash_file('sha256', $full);
  181. if ($hash === false) return null;
  182. $cache[$rel] = [(int)$st['size'], (int)$st['mtime'], $hash];
  183. $dirty = true;
  184. return $hash;
  185. }
  186. ​
  187. function sd_parse_files(mixed $files, int $max, bool $allowEmpty = false): ?array
  188. {
  189. if (!is_array($files) || count($files) > $max) return null;
  190. $out = [];
  191. foreach ($files as $f) {
  192. if (!is_array($f) || count($f) !== 3) return null;
  193. [$p, $s, $h] = $f;
  194. if (!is_int($s) || $s < 0 || !is_string($h)) return null;
  195. if (!($allowEmpty && $h === '') && !preg_match('/^[a-f0-9]{64}$/', $h)) return null;
  196. $out[] = [$p, $s, $h];
  197. }
  198. return $out;
  199. }
  200. ​
  201. function sd_input_json(): array
  202. {
  203. $raw = file_get_contents('php://input', false, null, 0, 16777216);
  204. $data = json_decode((string)$raw, true);
  205. if (!is_array($data)) sd_json(['error' => 'bad_request'], 400);
  206. return $data;
  207. }
  208. ​
  209. function sd_api_compare(): never
  210. {
  211. $in = sd_input_json();
  212. $target = sd_target((string)($in['target'] ?? ''));
  213. if (!$target) sd_json(['error' => 'target'], 400);
  214. $files = sd_parse_files($in['files'] ?? null, SD_MAX_BATCH, true);
  215. if ($files === null) sd_json(['error' => 'bad_request'], 400);
  216. $cache = sd_cache_load($target['root']);
  217. $dirty = false;
  218. $result = [];
  219. foreach ($files as [$p, $size, $hash]) {
  220. $rel = sd_rel($p);
  221. if ($rel === null) {
  222. $result[] = 'e';
  223. continue;
  224. }
  225. if (sd_excluded($rel, $target)) {
  226. $result[] = 'x';
  227. continue;
  228. }
  229. if ($size > SD_MAX_FILE) {
  230. $result[] = 'b';
  231. continue;
  232. }
  233. $full = sd_dest($target['root'], $rel, false);
  234. if ($full === null) {
  235. $result[] = 'e';
  236. continue;
  237. }
  238. if (!is_file($full)) {
  239. $result[] = 'n';
  240. continue;
  241. }
  242. if (@filesize($full) !== $size) {
  243. $result[] = 'c';
  244. continue;
  245. }
  246. if ($hash === '') {
  247. $result[] = '?';
  248. continue;
  249. }
  250. $result[] = sd_server_hash($full, $rel, $cache, $dirty) === $hash ? 's' : 'c';
  251. }
  252. if ($dirty) sd_cache_save($target['root'], $cache);
  253. sd_json(['r' => $result]);
  254. }
  255. ​
  256. function sd_api_view(): never
  257. {
  258. $in = sd_input_json();
  259. $target = sd_target((string)($in['target'] ?? ''));
  260. if (!$target) sd_json(['error' => 'target'], 400);
  261. $rel = sd_rel($in['path'] ?? null);
  262. if ($rel === null || sd_excluded($rel, $target)) sd_json(['error' => 'forbidden'], 403);
  263. $full = sd_dest($target['root'], $rel, false);
  264. if ($full === null) sd_json(['error' => 'forbidden'], 403);
  265. clearstatcache(true, $full);
  266. if (!is_file($full)) sd_json(['exists' => false]);
  267. $real = realpath($full);
  268. if ($real === false || !sd_inside($real, $target['root'])) sd_json(['error' => 'forbidden'], 403);
  269. $size = (int)@filesize($real);
  270. if ($size > SD_VIEW_MAX) sd_json(['exists' => true, 'size' => $size, 'large' => true]);
  271. $raw = @file_get_contents($real);
  272. if ($raw === false) sd_json(['error' => 'read'], 500);
  273. if (str_contains($raw, "\0") || !mb_check_encoding($raw, 'UTF-8')) sd_json(['exists' => true, 'size' => $size, 'binary' => true]);
  274. sd_json(['exists' => true, 'size' => $size, 'text' => $raw]);
  275. }
  276. ​
  277. function sd_staging_cleanup(): void
  278. {
  279. $dir = sd_data('staging');
  280. foreach (glob($dir . '/*', GLOB_ONLYDIR) ?: [] as $d) {
  281. if (time() - (int)@filemtime($d) > SD_STAGING_TTL) sd_rrmdir($d, $dir);
  282. }
  283. }
  284. ​
  285. function sd_deploy_dir(string $id): ?string
  286. {
  287. if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{8}$/', $id)) return null;
  288. $dir = sd_data('staging') . '/' . $id;
  289. return is_dir($dir) ? $dir : null;
  290. }
  291. ​
  292. function sd_manifest(string $dir): ?array
  293. {
  294. $m = json_decode((string)@file_get_contents($dir . '/manifest.json'), true);
  295. return is_array($m) ? $m : null;
  296. }
  297. ​
  298. function sd_api_begin(): never
  299. {
  300. $in = sd_input_json();
  301. $target = sd_target((string)($in['target'] ?? ''));
  302. if (!$target) sd_json(['error' => 'target'], 400);
  303. $files = sd_parse_files($in['files'] ?? null, SD_MAX_FILES);
  304. if (!$files) sd_json(['error' => 'bad_request'], 400);
  305. $seen = [];
  306. $clean = [];
  307. $total = 0;
  308. foreach ($files as [$p, $size, $hash]) {
  309. $rel = sd_rel($p);
  310. if ($rel === null || sd_excluded($rel, $target) || $size > SD_MAX_FILE || isset($seen[strtolower($rel)])) sd_json(['error' => 'bad_file', 'path' => (string)$p], 400);
  311. if (sd_dest($target['root'], $rel, false) === null) sd_json(['error' => 'bad_file', 'path' => $rel], 400);
  312. $seen[strtolower($rel)] = true;
  313. $clean[] = ['p' => $rel, 's' => $size, 'h' => $hash];
  314. $total += $size;
  315. }
  316. $free = @disk_free_space(sd_base());
  317. if ($free !== false && $free < $total * 3 + 52428800) sd_json(['error' => 'disk'], 507);
  318. sd_staging_cleanup();
  319. $id = gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4));
  320. $dir = sd_data('staging') . '/' . $id;
  321. if (!@mkdir($dir . '/parts', 0700, true)) sd_json(['error' => 'staging'], 500);
  322. $manifest = ['id' => $id, 'target' => $target['id'], 'created' => time(), 'bytes' => $total, 'files' => $clean];
  323. file_put_contents($dir . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
  324. sd_audit('deploy_begin', ['id' => $id, 'target' => $target['id'], 'files' => count($clean), 'bytes' => $total]);
  325. sd_json(['id' => $id, 'chunk' => SD_CHUNK]);
  326. }
  327. ​
  328. function sd_api_chunk(): never
  329. {
  330. $dir = sd_deploy_dir((string)($_SERVER['HTTP_X_SYNCDROP_DEPLOY'] ?? ''));
  331. $index = filter_var($_SERVER['HTTP_X_SYNCDROP_FILE'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
  332. $offset = filter_var($_SERVER['HTTP_X_SYNCDROP_OFFSET'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
  333. if ($dir === null || $index === false || $offset === false) sd_json(['error' => 'bad_request'], 400);
  334. $manifest = sd_manifest($dir);
  335. $file = $manifest['files'][$index] ?? null;
  336. if (!is_array($file)) sd_json(['error' => 'bad_request'], 400);
  337. $part = $dir . '/parts/' . $index;
  338. clearstatcache(true, $part);
  339. $current = is_file($part) ? (int)filesize($part) : 0;
  340. if ($offset !== $current) sd_json(['error' => 'offset', 'have' => $current], 409);
  341. $in = fopen('php://input', 'rb');
  342. $out = fopen($part, 'ab');
  343. if ($in === false || $out === false) sd_json(['error' => 'staging'], 500);
  344. flock($out, LOCK_EX);
  345. $written = 0;
  346. $limit = (int)$file['s'] - $offset;
  347. while (!feof($in)) {
  348. $buf = fread($in, 65536);
  349. if ($buf === false || $buf === '') break;
  350. $written += strlen($buf);
  351. if ($written > $limit || $written > SD_CHUNK) {
  352. ftruncate($out, $offset);
  353. flock($out, LOCK_UN);
  354. fclose($out);
  355. sd_json(['error' => 'too_large'], 413);
  356. }
  357. fwrite($out, $buf);
  358. }
  359. fflush($out);
  360. flock($out, LOCK_UN);
  361. fclose($out);
  362. fclose($in);
  363. @touch($dir);
  364. sd_json(['have' => $offset + $written]);
  365. }
  366. ​
  367. function sd_with_lock(callable $fn): mixed
  368. {
  369. $fp = fopen(sd_data('state') . '/commit.lock', 'c');
  370. if ($fp === false || !flock($fp, LOCK_EX | LOCK_NB)) sd_json(['error' => 'busy'], 409);
  371. try {
  372. return $fn();
  373. } finally {
  374. flock($fp, LOCK_UN);
  375. fclose($fp);
  376. }
  377. }
  378. ​
  379. function sd_place(string $source, string $dest, ?int $mode): bool
  380. {
  381. $tmp = dirname($dest) . DIRECTORY_SEPARATOR . '.sd-' . bin2hex(random_bytes(6)) . '.tmp';
  382. if (!@copy($source, $tmp)) {
  383. @unlink($tmp);
  384. return false;
  385. }
  386. @chmod($tmp, $mode ?? 0644);
  387. if (!@rename($tmp, $dest)) {
  388. @chmod($tmp, 0600);
  389. @unlink($tmp);
  390. return false;
  391. }
  392. if (function_exists('opcache_invalidate') && str_ends_with(strtolower($dest), '.php')) @opcache_invalidate($dest, true);
  393. return true;
  394. }
  395. ​
  396. function sd_history(): array
  397. {
  398. $h = sd_read_state('history');
  399. return array_values(array_filter($h['items'] ?? [], 'is_array'));
  400. }
  401. ​
  402. function sd_history_add(array $record): void
  403. {
  404. sd_state('history', static function (array &$h) use ($record): void {
  405. $items = $h['items'] ?? [];
  406. array_unshift($items, $record);
  407. $h['items'] = array_slice($items, 0, 200);
  408. });
  409. }
  410. ​
  411. function sd_history_update(string $id, array $changes): void
  412. {
  413. sd_state('history', static function (array &$h) use ($id, $changes): void {
  414. foreach ($h['items'] ?? [] as $i => $item) {
  415. if (($item['id'] ?? '') === $id) $h['items'][$i] = array_merge($item, $changes);
  416. }
  417. });
  418. }
  419. ​
  420. function sd_prune_backups(): void
  421. {
  422. $base = sd_data('backups');
  423. $keep = [];
  424. foreach (sd_history() as $item) {
  425. if (($item['status'] ?? '') === 'ok' && !empty($item['backup'])) $keep[] = $item['id'];
  426. }
  427. $keep = array_slice($keep, 0, SD_KEEP_BACKUPS);
  428. foreach (glob($base . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
  429. $id = basename($dir);
  430. if (!in_array($id, $keep, true)) {
  431. sd_rrmdir($dir, $base);
  432. sd_history_update($id, ['backup' => false]);
  433. }
  434. }
  435. }
  436. ​
  437. function sd_api_commit(): never
  438. {
  439. $in = sd_input_json();
  440. $id = (string)($in['id'] ?? '');
  441. $dir = sd_deploy_dir($id);
  442. if ($dir === null) sd_json(['error' => 'bad_request'], 400);
  443. $result = sd_with_lock(static fn(): array => sd_commit($id, $dir));
  444. sd_json($result, isset($result['error']) ? 422 : 200);
  445. }
  446. ​
  447. function sd_commit(string $id, string $dir): array
  448. {
  449. $manifest = sd_manifest($dir);
  450. $target = $manifest ? sd_target((string)$manifest['target']) : null;
  451. if (!$manifest || !$target) return ['error' => 'target'];
  452. $bad = [];
  453. foreach ($manifest['files'] as $i => $f) {
  454. $part = $dir . '/parts/' . $i;
  455. if ((int)$f['s'] === 0 && !is_file($part)) @touch($part);
  456. clearstatcache(true, $part);
  457. if (!is_file($part) || (int)filesize($part) !== (int)$f['s'] || !hash_equals((string)$f['h'], (string)hash_file('sha256', $part))) $bad[] = $f['p'];
  458. }
  459. if ($bad) {
  460. sd_audit('deploy_verify_failed', ['id' => $id, 'count' => count($bad)]);
  461. return ['error' => 'verify', 'files' => array_slice($bad, 0, 50)];
  462. }
  463. ​
  464. $backupDir = sd_data('backups') . '/' . $id;
  465. $createdDirs = [];
  466. $plan = [];
  467. foreach ($manifest['files'] as $i => $f) {
  468. $dest = sd_dest($target['root'], $f['p'], true, $createdDirs);
  469. if ($dest === null || sd_excluded($f['p'], $target)) {
  470. sd_undo_dirs($createdDirs);
  471. return ['error' => 'unsafe', 'files' => [$f['p']]];
  472. }
  473. $exists = is_file($dest);
  474. $mode = $exists ? (@fileperms($dest) & 0777) : null;
  475. if ($exists) {
  476. $backup = $backupDir . '/files/' . $f['p'];
  477. if (!is_dir(dirname($backup)) && !@mkdir(dirname($backup), 0700, true)) {
  478. sd_undo_dirs($createdDirs);
  479. sd_rrmdir($backupDir, sd_data('backups'));
  480. return ['error' => 'backup', 'files' => [$f['p']]];
  481. }
  482. if (!@copy($dest, $backup)) {
  483. sd_undo_dirs($createdDirs);
  484. sd_rrmdir($backupDir, sd_data('backups'));
  485. return ['error' => 'backup', 'files' => [$f['p']]];
  486. }
  487. }
  488. $plan[] = ['i' => $i, 'p' => $f['p'], 'h' => $f['h'], 'dest' => $dest, 'k' => $exists ? 'c' : 'n', 'mode' => $mode ?: null];
  489. }
  490. ​
  491. $done = [];
  492. foreach ($plan as $step) {
  493. if (!sd_place($dir . '/parts/' . $step['i'], $step['dest'], $step['mode'])) {
  494. sd_restore($done, $backupDir);
  495. sd_undo_dirs($createdDirs);
  496. sd_rrmdir($backupDir, sd_data('backups'));
  497. sd_audit('deploy_rolled_back', ['id' => $id, 'file' => $step['p']]);
  498. sd_history_add(['id' => $id, 'target' => $target['id'], 't' => time(), 'n' => 0, 'c' => 0, 'bytes' => 0, 'status' => 'failed', 'backup' => false]);
  499. return ['error' => 'write', 'files' => [$step['p']], 'rolled_back' => true];
  500. }
  501. $done[] = $step;
  502. }
  503. ​
  504. $record = [
  505. 'id' => $id,
  506. 'target' => $target['id'],
  507. 't' => time(),
  508. 'files' => array_map(static fn($s) => [$s['p'], $s['k'], $s['h']], $plan),
  509. 'dirs' => array_map(static fn($d) => ltrim(str_replace('\\', '/', substr($d, strlen($target['root']))), '/'), $createdDirs),
  510. ];
  511. if (!is_dir($backupDir)) @mkdir($backupDir, 0700, true);
  512. file_put_contents($backupDir . '/record.json', json_encode($record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
  513. ​
  514. $cache = sd_cache_load($target['root']);
  515. foreach ($plan as $step) {
  516. clearstatcache(true, $step['dest']);
  517. $st = @stat($step['dest']);
  518. if ($st) $cache[$step['p']] = [(int)$st['size'], (int)$st['mtime'], $step['h']];
  519. }
  520. sd_cache_save($target['root'], $cache);
  521. ​
  522. $new = count(array_filter($plan, static fn($s) => $s['k'] === 'n'));
  523. sd_history_add(['id' => $id, 'target' => $target['id'], 't' => time(), 'n' => $new, 'c' => count($plan) - $new, 'bytes' => (int)$manifest['bytes'], 'status' => 'ok', 'backup' => true]);
  524. sd_rrmdir($dir, sd_data('staging'));
  525. sd_prune_backups();
  526. sd_audit('deploy_ok', ['id' => $id, 'target' => $target['id'], 'new' => $new, 'changed' => count($plan) - $new]);
  527. return ['ok' => true, 'id' => $id, 'new' => $new, 'changed' => count($plan) - $new];
  528. }
  529. ​
  530. function sd_restore(array $done, string $backupDir): void
  531. {
  532. foreach (array_reverse($done) as $step) {
  533. if ($step['k'] === 'c') {
  534. sd_place($backupDir . '/files/' . $step['p'], $step['dest'], $step['mode']);
  535. } else {
  536. @unlink($step['dest']);
  537. }
  538. }
  539. }
  540. ​
  541. function sd_undo_dirs(array $dirs): void
  542. {
  543. foreach (array_reverse($dirs) as $d) @rmdir($d);
  544. }
  545. ​
  546. function sd_api_abort(): never
  547. {
  548. $in = sd_input_json();
  549. $dir = sd_deploy_dir((string)($in['id'] ?? ''));
  550. if ($dir !== null) {
  551. sd_rrmdir($dir, sd_data('staging'));
  552. sd_audit('deploy_aborted', ['id' => (string)$in['id']]);
  553. }
  554. sd_json(['ok' => true]);
  555. }
  556. ​
  557. function sd_rollback_allowed(array $item): bool
  558. {
  559. if (($item['status'] ?? '') !== 'ok' || empty($item['backup'])) return false;
  560. foreach (sd_history() as $other) {
  561. if (($other['target'] ?? '') !== ($item['target'] ?? '')) continue;
  562. if (($other['status'] ?? '') !== 'ok') continue;
  563. return ($other['id'] ?? '') === ($item['id'] ?? '');
  564. }
  565. return false;
  566. }
  567. ​
  568. function sd_rollback(string $id): array
  569. {
  570. if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{8}$/', $id)) return ['error' => 'bad_request'];
  571. $item = null;
  572. foreach (sd_history() as $h) {
  573. if (($h['id'] ?? '') === $id) $item = $h;
  574. }
  575. if (!$item || !sd_rollback_allowed($item)) return ['error' => 'not_allowed'];
  576. $target = sd_target((string)$item['target']);
  577. $backupDir = sd_data('backups') . '/' . $id;
  578. $record = json_decode((string)@file_get_contents($backupDir . '/record.json'), true);
  579. if (!$target || !is_array($record)) return ['error' => 'not_allowed'];
  580. $restored = 0;
  581. $skipped = [];
  582. $cache = sd_cache_load($target['root']);
  583. foreach (array_reverse($record['files'] ?? []) as [$rel, $kind, $hash]) {
  584. $rel = sd_rel($rel);
  585. if ($rel === null) continue;
  586. $dest = sd_dest($target['root'], $rel, false);
  587. if ($dest === null) {
  588. $skipped[] = $rel;
  589. continue;
  590. }
  591. if (is_file($dest) && !hash_equals((string)$hash, (string)hash_file('sha256', $dest))) {
  592. $skipped[] = $rel;
  593. continue;
  594. }
  595. if ($kind === 'c') {
  596. $backup = $backupDir . '/files/' . $rel;
  597. if (is_file($backup) && sd_place($backup, $dest, @fileperms($dest) & 0777 ?: null)) $restored++;
  598. else $skipped[] = $rel;
  599. } elseif (is_file($dest)) {
  600. if (@unlink($dest)) $restored++;
  601. else $skipped[] = $rel;
  602. }
  603. unset($cache[$rel]);
  604. }
  605. foreach (array_reverse($record['dirs'] ?? []) as $d) {
  606. $d = sd_rel($d);
  607. if ($d !== null) @rmdir($target['root'] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $d));
  608. }
  609. sd_cache_save($target['root'], $cache);
  610. sd_history_update($id, ['status' => 'rolled_back', 'backup' => false, 'rt' => time()]);
  611. sd_rrmdir($backupDir, sd_data('backups'));
  612. sd_audit('rollback', ['id' => $id, 'restored' => $restored, 'skipped' => count($skipped)]);
  613. return ['ok' => true, 'restored' => $restored, 'skipped' => $skipped];
  614. }
  615. ​
  616. function sd_api(string $action): never
  617. {
  618. if (!isset($_COOKIE[sd_cookie_name()])) {
  619. sd_threat('api_anon');
  620. sd_json(['error' => 'auth'], 401);
  621. }
  622. if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') sd_json(['error' => 'method'], 405);
  623. if (sd_env() === null) sd_json(['error' => 'auth'], 401);
  624. sd_session_start();
  625. if (sd_stage() !== 'full') sd_json(['error' => 'auth'], 401);
  626. if (!sd_csrf_ok()) sd_json(['error' => 'csrf'], 403);
  627. session_write_close();
  628. @set_time_limit(300);
  629. match ($action) {
  630. 'compare' => sd_api_compare(),
  631. 'begin' => sd_api_begin(),
  632. 'chunk' => sd_api_chunk(),
  633. 'commit' => sd_api_commit(),
  634. 'abort' => sd_api_abort(),
  635. 'view' => sd_api_view(),
  636. default => sd_json(['error' => 'not_found'], 404),
  637. };
  638. }
  639. ​