v1.3.0
AtheraStudio SyncDrop
- <?php
- declare(strict_types=1);
-
- defined('SD_BOOT') || exit;
-
- const SD_DEFAULT_EXCLUDES = ['node_modules/', '.DS_Store', 'Thumbs.db', 'desktop.ini', '*.log', '*.sqlite', '*.sqlite3', '*.swp', '.idea/', '.vscode/', '.htpasswd'];
- const SD_STAGING_TTL = 86400;
- const SD_VIEW_MAX = 1048576;
-
- function sd_targets(): array
- {
- $targets = sd_env()['SD_TARGETS'] ?? [];
- return is_array($targets) ? $targets : [];
- }
-
- function sd_target(string $id): ?array
- {
- $t = sd_targets()[$id] ?? null;
- if (!is_array($t)) return null;
- $root = realpath((string)($t['path'] ?? ''));
- if ($root === false || !is_dir($root) || !is_writable($root)) return null;
- $t['id'] = $id;
- $t['root'] = $root;
- $t['exclude'] = array_values(array_filter((array)($t['exclude'] ?? []), 'is_string'));
- $app = realpath(SD_ROOT);
- $t['app_rel'] = null;
- if ($app !== false && sd_inside($app, $root) && $app !== $root) {
- $t['app_rel'] = ltrim(str_replace('\\', '/', substr($app, strlen($root))), '/');
- }
- return $t;
- }
-
- function sd_target_path_check(string $path, ?string &$error): ?string
- {
- $error = null;
- $real = realpath(trim($path));
- if ($real === false || !is_dir($real)) {
- $error = 'err_target_missing';
- return null;
- }
- if (dirname($real) === $real) {
- $error = 'err_target_root';
- return null;
- }
- if (!is_writable($real)) {
- $error = 'err_target_readonly';
- return null;
- }
- $data = realpath(sd_data());
- $app = realpath(SD_ROOT);
- if (($data !== false && sd_inside($real, $data)) || ($app !== false && sd_inside($real, $app))) {
- $error = 'err_target_forbidden';
- return null;
- }
- return $real;
- }
-
- function sd_rel(mixed $path): ?string
- {
- if (!is_string($path) || $path === '' || strlen($path) > 1024 || str_contains($path, "\0")) return null;
- $path = str_replace('\\', '/', $path);
- if ($path[0] === '/' || preg_match('/^[A-Za-z]:/', $path) || !mb_check_encoding($path, 'UTF-8')) return null;
- $parts = explode('/', $path);
- foreach ($parts as $segment) {
- if ($segment === '' || $segment === '.' || $segment === '..' || strlen($segment) > 255 || preg_match('/[\x00-\x1F\x7F]/', $segment)) return null;
- }
- return implode('/', $parts);
- }
-
- function sd_always_excluded(string $rel, array $target): bool
- {
- $segments = explode('/', strtolower($rel));
- foreach ($segments as $segment) {
- if (in_array($segment, ['.git', '.svn', '.hg'], true) || str_starts_with($segment, '.syncdrop')) return true;
- }
- $name = (string)end($segments);
- if ($name === '.env' || str_starts_with($name, '.env.') || str_ends_with($name, '.env')) return true;
- if (str_starts_with($name, '.sd-') && str_ends_with($name, '.tmp')) return true;
- $app = $target['app_rel'] ?? null;
- if (is_string($app) && $app !== '' && ($rel === $app || str_starts_with($rel, $app . '/'))) return true;
- return false;
- }
-
- function sd_glob_regex(string $glob): string
- {
- $re = '';
- $n = strlen($glob);
- for ($i = 0; $i < $n; $i++) {
- $c = $glob[$i];
- if ($c === '*') {
- if ($i + 1 < $n && $glob[$i + 1] === '*') {
- $re .= '.*';
- $i++;
- } else {
- $re .= '[^/]*';
- }
- } elseif ($c === '?') {
- $re .= '[^/]';
- } else {
- $re .= preg_quote($c, '#');
- }
- }
- return '#^' . $re . '$#iu';
- }
-
- function sd_excluded(string $rel, array $target): bool
- {
- if (sd_always_excluded($rel, $target)) return true;
- $segments = explode('/', $rel);
- $dirs = array_slice($segments, 0, -1);
- foreach ($target['exclude'] as $pattern) {
- $pattern = trim($pattern);
- if ($pattern === '' || $pattern[0] === '#') continue;
- $dirOnly = str_ends_with($pattern, '/');
- $pattern = trim($pattern, '/');
- if ($pattern === '') continue;
- $anchored = str_contains($pattern, '/');
- $re = sd_glob_regex($pattern);
- if ($anchored) {
- $count = count($dirs);
- for ($k = 1; $k <= $count; $k++) {
- if (preg_match($re, implode('/', array_slice($dirs, 0, $k)))) return true;
- }
- if (!$dirOnly && preg_match($re, $rel)) return true;
- } else {
- foreach ($dirOnly ? $dirs : $segments as $segment) {
- if (preg_match($re, $segment)) return true;
- }
- }
- }
- return false;
- }
-
- function sd_dest(string $root, string $rel, bool $create, array &$createdDirs = []): ?string
- {
- $parts = explode('/', $rel);
- $file = array_pop($parts);
- $dir = $root;
- foreach ($parts as $part) {
- $dir .= DIRECTORY_SEPARATOR . $part;
- if (is_link($dir)) return null;
- if (is_dir($dir)) continue;
- if (file_exists($dir)) return null;
- if (!$create) continue;
- if (!@mkdir($dir, 0755)) return null;
- $createdDirs[] = $dir;
- }
- $full = $dir . DIRECTORY_SEPARATOR . $file;
- if (is_link($full) || is_dir($full)) return null;
- return $full;
- }
-
- function sd_cache_file(string $root): string
- {
- return sd_data('cache') . '/' . sha1($root) . '.json';
- }
-
- function sd_cache_load(string $root): array
- {
- $data = json_decode((string)@file_get_contents(sd_cache_file($root)), true);
- return is_array($data) ? $data : [];
- }
-
- function sd_cache_save(string $root, array $cache): void
- {
- $file = sd_cache_file($root);
- $tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
- if (@file_put_contents($tmp, json_encode($cache, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX) !== false) {
- @rename($tmp, $file) || @unlink($tmp);
- }
- }
-
- function sd_server_hash(string $full, string $rel, array &$cache, bool &$dirty): ?string
- {
- clearstatcache(true, $full);
- $st = @stat($full);
- if ($st === false) return null;
- $entry = $cache[$rel] ?? null;
- if (is_array($entry) && (int)$entry[0] === (int)$st['size'] && (int)$entry[1] === (int)$st['mtime']) return (string)$entry[2];
- $hash = @hash_file('sha256', $full);
- if ($hash === false) return null;
- $cache[$rel] = [(int)$st['size'], (int)$st['mtime'], $hash];
- $dirty = true;
- return $hash;
- }
-
- function sd_parse_files(mixed $files, int $max, bool $allowEmpty = false): ?array
- {
- if (!is_array($files) || count($files) > $max) return null;
- $out = [];
- foreach ($files as $f) {
- if (!is_array($f) || count($f) !== 3) return null;
- [$p, $s, $h] = $f;
- if (!is_int($s) || $s < 0 || !is_string($h)) return null;
- if (!($allowEmpty && $h === '') && !preg_match('/^[a-f0-9]{64}$/', $h)) return null;
- $out[] = [$p, $s, $h];
- }
- return $out;
- }
-
- function sd_input_json(): array
- {
- $raw = file_get_contents('php://input', false, null, 0, 16777216);
- $data = json_decode((string)$raw, true);
- if (!is_array($data)) sd_json(['error' => 'bad_request'], 400);
- return $data;
- }
-
- function sd_api_compare(): never
- {
- $in = sd_input_json();
- $target = sd_target((string)($in['target'] ?? ''));
- if (!$target) sd_json(['error' => 'target'], 400);
- $files = sd_parse_files($in['files'] ?? null, SD_MAX_BATCH, true);
- if ($files === null) sd_json(['error' => 'bad_request'], 400);
- $cache = sd_cache_load($target['root']);
- $dirty = false;
- $result = [];
- foreach ($files as [$p, $size, $hash]) {
- $rel = sd_rel($p);
- if ($rel === null) {
- $result[] = 'e';
- continue;
- }
- if (sd_excluded($rel, $target)) {
- $result[] = 'x';
- continue;
- }
- if ($size > SD_MAX_FILE) {
- $result[] = 'b';
- continue;
- }
- $full = sd_dest($target['root'], $rel, false);
- if ($full === null) {
- $result[] = 'e';
- continue;
- }
- if (!is_file($full)) {
- $result[] = 'n';
- continue;
- }
- if (@filesize($full) !== $size) {
- $result[] = 'c';
- continue;
- }
- if ($hash === '') {
- $result[] = '?';
- continue;
- }
- $result[] = sd_server_hash($full, $rel, $cache, $dirty) === $hash ? 's' : 'c';
- }
- if ($dirty) sd_cache_save($target['root'], $cache);
- sd_json(['r' => $result]);
- }
-
- function sd_api_view(): never
- {
- $in = sd_input_json();
- $target = sd_target((string)($in['target'] ?? ''));
- if (!$target) sd_json(['error' => 'target'], 400);
- $rel = sd_rel($in['path'] ?? null);
- if ($rel === null || sd_excluded($rel, $target)) sd_json(['error' => 'forbidden'], 403);
- $full = sd_dest($target['root'], $rel, false);
- if ($full === null) sd_json(['error' => 'forbidden'], 403);
- clearstatcache(true, $full);
- if (!is_file($full)) sd_json(['exists' => false]);
- $real = realpath($full);
- if ($real === false || !sd_inside($real, $target['root'])) sd_json(['error' => 'forbidden'], 403);
- $size = (int)@filesize($real);
- if ($size > SD_VIEW_MAX) sd_json(['exists' => true, 'size' => $size, 'large' => true]);
- $raw = @file_get_contents($real);
- if ($raw === false) sd_json(['error' => 'read'], 500);
- if (str_contains($raw, "\0") || !mb_check_encoding($raw, 'UTF-8')) sd_json(['exists' => true, 'size' => $size, 'binary' => true]);
- sd_json(['exists' => true, 'size' => $size, 'text' => $raw]);
- }
-
- function sd_staging_cleanup(): void
- {
- $dir = sd_data('staging');
- foreach (glob($dir . '/*', GLOB_ONLYDIR) ?: [] as $d) {
- if (time() - (int)@filemtime($d) > SD_STAGING_TTL) sd_rrmdir($d, $dir);
- }
- }
-
- function sd_deploy_dir(string $id): ?string
- {
- if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{8}$/', $id)) return null;
- $dir = sd_data('staging') . '/' . $id;
- return is_dir($dir) ? $dir : null;
- }
-
- function sd_manifest(string $dir): ?array
- {
- $m = json_decode((string)@file_get_contents($dir . '/manifest.json'), true);
- return is_array($m) ? $m : null;
- }
-
- function sd_api_begin(): never
- {
- $in = sd_input_json();
- $target = sd_target((string)($in['target'] ?? ''));
- if (!$target) sd_json(['error' => 'target'], 400);
- $files = sd_parse_files($in['files'] ?? null, SD_MAX_FILES);
- if (!$files) sd_json(['error' => 'bad_request'], 400);
- $seen = [];
- $clean = [];
- $total = 0;
- foreach ($files as [$p, $size, $hash]) {
- $rel = sd_rel($p);
- if ($rel === null || sd_excluded($rel, $target) || $size > SD_MAX_FILE || isset($seen[strtolower($rel)])) sd_json(['error' => 'bad_file', 'path' => (string)$p], 400);
- if (sd_dest($target['root'], $rel, false) === null) sd_json(['error' => 'bad_file', 'path' => $rel], 400);
- $seen[strtolower($rel)] = true;
- $clean[] = ['p' => $rel, 's' => $size, 'h' => $hash];
- $total += $size;
- }
- $free = @disk_free_space(sd_base());
- if ($free !== false && $free < $total * 3 + 52428800) sd_json(['error' => 'disk'], 507);
- sd_staging_cleanup();
- $id = gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4));
- $dir = sd_data('staging') . '/' . $id;
- if (!@mkdir($dir . '/parts', 0700, true)) sd_json(['error' => 'staging'], 500);
- $manifest = ['id' => $id, 'target' => $target['id'], 'created' => time(), 'bytes' => $total, 'files' => $clean];
- file_put_contents($dir . '/manifest.json', json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
- sd_audit('deploy_begin', ['id' => $id, 'target' => $target['id'], 'files' => count($clean), 'bytes' => $total]);
- sd_json(['id' => $id, 'chunk' => SD_CHUNK]);
- }
-
- function sd_api_chunk(): never
- {
- $dir = sd_deploy_dir((string)($_SERVER['HTTP_X_SYNCDROP_DEPLOY'] ?? ''));
- $index = filter_var($_SERVER['HTTP_X_SYNCDROP_FILE'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
- $offset = filter_var($_SERVER['HTTP_X_SYNCDROP_OFFSET'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
- if ($dir === null || $index === false || $offset === false) sd_json(['error' => 'bad_request'], 400);
- $manifest = sd_manifest($dir);
- $file = $manifest['files'][$index] ?? null;
- if (!is_array($file)) sd_json(['error' => 'bad_request'], 400);
- $part = $dir . '/parts/' . $index;
- clearstatcache(true, $part);
- $current = is_file($part) ? (int)filesize($part) : 0;
- if ($offset !== $current) sd_json(['error' => 'offset', 'have' => $current], 409);
- $in = fopen('php://input', 'rb');
- $out = fopen($part, 'ab');
- if ($in === false || $out === false) sd_json(['error' => 'staging'], 500);
- flock($out, LOCK_EX);
- $written = 0;
- $limit = (int)$file['s'] - $offset;
- while (!feof($in)) {
- $buf = fread($in, 65536);
- if ($buf === false || $buf === '') break;
- $written += strlen($buf);
- if ($written > $limit || $written > SD_CHUNK) {
- ftruncate($out, $offset);
- flock($out, LOCK_UN);
- fclose($out);
- sd_json(['error' => 'too_large'], 413);
- }
- fwrite($out, $buf);
- }
- fflush($out);
- flock($out, LOCK_UN);
- fclose($out);
- fclose($in);
- @touch($dir);
- sd_json(['have' => $offset + $written]);
- }
-
- function sd_with_lock(callable $fn): mixed
- {
- $fp = fopen(sd_data('state') . '/commit.lock', 'c');
- if ($fp === false || !flock($fp, LOCK_EX | LOCK_NB)) sd_json(['error' => 'busy'], 409);
- try {
- return $fn();
- } finally {
- flock($fp, LOCK_UN);
- fclose($fp);
- }
- }
-
- function sd_place(string $source, string $dest, ?int $mode): bool
- {
- $tmp = dirname($dest) . DIRECTORY_SEPARATOR . '.sd-' . bin2hex(random_bytes(6)) . '.tmp';
- if (!@copy($source, $tmp)) {
- @unlink($tmp);
- return false;
- }
- @chmod($tmp, $mode ?? 0644);
- if (!@rename($tmp, $dest)) {
- @chmod($tmp, 0600);
- @unlink($tmp);
- return false;
- }
- if (function_exists('opcache_invalidate') && str_ends_with(strtolower($dest), '.php')) @opcache_invalidate($dest, true);
- return true;
- }
-
- function sd_history(): array
- {
- $h = sd_read_state('history');
- return array_values(array_filter($h['items'] ?? [], 'is_array'));
- }
-
- function sd_history_add(array $record): void
- {
- sd_state('history', static function (array &$h) use ($record): void {
- $items = $h['items'] ?? [];
- array_unshift($items, $record);
- $h['items'] = array_slice($items, 0, 200);
- });
- }
-
- function sd_history_update(string $id, array $changes): void
- {
- sd_state('history', static function (array &$h) use ($id, $changes): void {
- foreach ($h['items'] ?? [] as $i => $item) {
- if (($item['id'] ?? '') === $id) $h['items'][$i] = array_merge($item, $changes);
- }
- });
- }
-
- function sd_prune_backups(): void
- {
- $base = sd_data('backups');
- $keep = [];
- foreach (sd_history() as $item) {
- if (($item['status'] ?? '') === 'ok' && !empty($item['backup'])) $keep[] = $item['id'];
- }
- $keep = array_slice($keep, 0, SD_KEEP_BACKUPS);
- foreach (glob($base . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
- $id = basename($dir);
- if (!in_array($id, $keep, true)) {
- sd_rrmdir($dir, $base);
- sd_history_update($id, ['backup' => false]);
- }
- }
- }
-
- function sd_api_commit(): never
- {
- $in = sd_input_json();
- $id = (string)($in['id'] ?? '');
- $dir = sd_deploy_dir($id);
- if ($dir === null) sd_json(['error' => 'bad_request'], 400);
- $result = sd_with_lock(static fn(): array => sd_commit($id, $dir));
- sd_json($result, isset($result['error']) ? 422 : 200);
- }
-
- function sd_commit(string $id, string $dir): array
- {
- $manifest = sd_manifest($dir);
- $target = $manifest ? sd_target((string)$manifest['target']) : null;
- if (!$manifest || !$target) return ['error' => 'target'];
- $bad = [];
- foreach ($manifest['files'] as $i => $f) {
- $part = $dir . '/parts/' . $i;
- if ((int)$f['s'] === 0 && !is_file($part)) @touch($part);
- clearstatcache(true, $part);
- if (!is_file($part) || (int)filesize($part) !== (int)$f['s'] || !hash_equals((string)$f['h'], (string)hash_file('sha256', $part))) $bad[] = $f['p'];
- }
- if ($bad) {
- sd_audit('deploy_verify_failed', ['id' => $id, 'count' => count($bad)]);
- return ['error' => 'verify', 'files' => array_slice($bad, 0, 50)];
- }
-
- $backupDir = sd_data('backups') . '/' . $id;
- $createdDirs = [];
- $plan = [];
- foreach ($manifest['files'] as $i => $f) {
- $dest = sd_dest($target['root'], $f['p'], true, $createdDirs);
- if ($dest === null || sd_excluded($f['p'], $target)) {
- sd_undo_dirs($createdDirs);
- return ['error' => 'unsafe', 'files' => [$f['p']]];
- }
- $exists = is_file($dest);
- $mode = $exists ? (@fileperms($dest) & 0777) : null;
- if ($exists) {
- $backup = $backupDir . '/files/' . $f['p'];
- if (!is_dir(dirname($backup)) && !@mkdir(dirname($backup), 0700, true)) {
- sd_undo_dirs($createdDirs);
- sd_rrmdir($backupDir, sd_data('backups'));
- return ['error' => 'backup', 'files' => [$f['p']]];
- }
- if (!@copy($dest, $backup)) {
- sd_undo_dirs($createdDirs);
- sd_rrmdir($backupDir, sd_data('backups'));
- return ['error' => 'backup', 'files' => [$f['p']]];
- }
- }
- $plan[] = ['i' => $i, 'p' => $f['p'], 'h' => $f['h'], 'dest' => $dest, 'k' => $exists ? 'c' : 'n', 'mode' => $mode ?: null];
- }
-
- $done = [];
- foreach ($plan as $step) {
- if (!sd_place($dir . '/parts/' . $step['i'], $step['dest'], $step['mode'])) {
- sd_restore($done, $backupDir);
- sd_undo_dirs($createdDirs);
- sd_rrmdir($backupDir, sd_data('backups'));
- sd_audit('deploy_rolled_back', ['id' => $id, 'file' => $step['p']]);
- sd_history_add(['id' => $id, 'target' => $target['id'], 't' => time(), 'n' => 0, 'c' => 0, 'bytes' => 0, 'status' => 'failed', 'backup' => false]);
- return ['error' => 'write', 'files' => [$step['p']], 'rolled_back' => true];
- }
- $done[] = $step;
- }
-
- $record = [
- 'id' => $id,
- 'target' => $target['id'],
- 't' => time(),
- 'files' => array_map(static fn($s) => [$s['p'], $s['k'], $s['h']], $plan),
- 'dirs' => array_map(static fn($d) => ltrim(str_replace('\\', '/', substr($d, strlen($target['root']))), '/'), $createdDirs),
- ];
- if (!is_dir($backupDir)) @mkdir($backupDir, 0700, true);
- file_put_contents($backupDir . '/record.json', json_encode($record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
-
- $cache = sd_cache_load($target['root']);
- foreach ($plan as $step) {
- clearstatcache(true, $step['dest']);
- $st = @stat($step['dest']);
- if ($st) $cache[$step['p']] = [(int)$st['size'], (int)$st['mtime'], $step['h']];
- }
- sd_cache_save($target['root'], $cache);
-
- $new = count(array_filter($plan, static fn($s) => $s['k'] === 'n'));
- sd_history_add(['id' => $id, 'target' => $target['id'], 't' => time(), 'n' => $new, 'c' => count($plan) - $new, 'bytes' => (int)$manifest['bytes'], 'status' => 'ok', 'backup' => true]);
- sd_rrmdir($dir, sd_data('staging'));
- sd_prune_backups();
- sd_audit('deploy_ok', ['id' => $id, 'target' => $target['id'], 'new' => $new, 'changed' => count($plan) - $new]);
- return ['ok' => true, 'id' => $id, 'new' => $new, 'changed' => count($plan) - $new];
- }
-
- function sd_restore(array $done, string $backupDir): void
- {
- foreach (array_reverse($done) as $step) {
- if ($step['k'] === 'c') {
- sd_place($backupDir . '/files/' . $step['p'], $step['dest'], $step['mode']);
- } else {
- @unlink($step['dest']);
- }
- }
- }
-
- function sd_undo_dirs(array $dirs): void
- {
- foreach (array_reverse($dirs) as $d) @rmdir($d);
- }
-
- function sd_api_abort(): never
- {
- $in = sd_input_json();
- $dir = sd_deploy_dir((string)($in['id'] ?? ''));
- if ($dir !== null) {
- sd_rrmdir($dir, sd_data('staging'));
- sd_audit('deploy_aborted', ['id' => (string)$in['id']]);
- }
- sd_json(['ok' => true]);
- }
-
- function sd_rollback_allowed(array $item): bool
- {
- if (($item['status'] ?? '') !== 'ok' || empty($item['backup'])) return false;
- foreach (sd_history() as $other) {
- if (($other['target'] ?? '') !== ($item['target'] ?? '')) continue;
- if (($other['status'] ?? '') !== 'ok') continue;
- return ($other['id'] ?? '') === ($item['id'] ?? '');
- }
- return false;
- }
-
- function sd_rollback(string $id): array
- {
- if (!preg_match('/^\d{8}-\d{6}-[a-f0-9]{8}$/', $id)) return ['error' => 'bad_request'];
- $item = null;
- foreach (sd_history() as $h) {
- if (($h['id'] ?? '') === $id) $item = $h;
- }
- if (!$item || !sd_rollback_allowed($item)) return ['error' => 'not_allowed'];
- $target = sd_target((string)$item['target']);
- $backupDir = sd_data('backups') . '/' . $id;
- $record = json_decode((string)@file_get_contents($backupDir . '/record.json'), true);
- if (!$target || !is_array($record)) return ['error' => 'not_allowed'];
- $restored = 0;
- $skipped = [];
- $cache = sd_cache_load($target['root']);
- foreach (array_reverse($record['files'] ?? []) as [$rel, $kind, $hash]) {
- $rel = sd_rel($rel);
- if ($rel === null) continue;
- $dest = sd_dest($target['root'], $rel, false);
- if ($dest === null) {
- $skipped[] = $rel;
- continue;
- }
- if (is_file($dest) && !hash_equals((string)$hash, (string)hash_file('sha256', $dest))) {
- $skipped[] = $rel;
- continue;
- }
- if ($kind === 'c') {
- $backup = $backupDir . '/files/' . $rel;
- if (is_file($backup) && sd_place($backup, $dest, @fileperms($dest) & 0777 ?: null)) $restored++;
- else $skipped[] = $rel;
- } elseif (is_file($dest)) {
- if (@unlink($dest)) $restored++;
- else $skipped[] = $rel;
- }
- unset($cache[$rel]);
- }
- foreach (array_reverse($record['dirs'] ?? []) as $d) {
- $d = sd_rel($d);
- if ($d !== null) @rmdir($target['root'] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $d));
- }
- sd_cache_save($target['root'], $cache);
- sd_history_update($id, ['status' => 'rolled_back', 'backup' => false, 'rt' => time()]);
- sd_rrmdir($backupDir, sd_data('backups'));
- sd_audit('rollback', ['id' => $id, 'restored' => $restored, 'skipped' => count($skipped)]);
- return ['ok' => true, 'restored' => $restored, 'skipped' => $skipped];
- }
-
- function sd_api(string $action): never
- {
- if (!isset($_COOKIE[sd_cookie_name()])) {
- sd_threat('api_anon');
- sd_json(['error' => 'auth'], 401);
- }
- if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') sd_json(['error' => 'method'], 405);
- if (sd_env() === null) sd_json(['error' => 'auth'], 401);
- sd_session_start();
- if (sd_stage() !== 'full') sd_json(['error' => 'auth'], 401);
- if (!sd_csrf_ok()) sd_json(['error' => 'csrf'], 403);
- session_write_close();
- @set_time_limit(300);
- match ($action) {
- 'compare' => sd_api_compare(),
- 'begin' => sd_api_begin(),
- 'chunk' => sd_api_chunk(),
- 'commit' => sd_api_commit(),
- 'abort' => sd_api_abort(),
- 'view' => sd_api_view(),
- default => sd_json(['error' => 'not_found'], 404),
- };
- }
-