" or "X-SWPB-Key: ", * or an administrator authenticated with an Application Password): * GET /status, GET /backups, POST /backups, GET /job * * External cron (secret key in the URL): GET|POST /cron?key=... */ final class SWPB_Rest { private SWPB_Settings $settings; private SWPB_Registry $registry; private SWPB_Destinations $destinations; private SWPB_Remote_Auth $remote; private SWPB_Runner $runner; private SWPB_Scheduler $scheduler; private SWPB_Preflight $preflight; private SWPB_Size_Report $size_report; public function __construct(SWPB_Settings $settings, SWPB_Registry $registry, SWPB_Destinations $destinations, SWPB_Remote_Auth $remote, SWPB_Runner $runner, SWPB_Scheduler $scheduler, SWPB_Preflight $preflight, SWPB_Size_Report $size_report) { $this->settings = $settings; $this->registry = $registry; $this->destinations = $destinations; $this->remote = $remote; $this->runner = $runner; $this->scheduler = $scheduler; $this->preflight = $preflight; $this->size_report = $size_report; } public function register_routes(): void { $ns = SWPB_REST_NAMESPACE; $admin = [$this, 'admin_permission']; $remote = [$this, 'remote_permission']; $type_arg = [ 'type' => [ 'type' => 'string', 'enum' => SWPB_Settings::TYPES, 'default' => 'full', ], ]; register_rest_route($ns, '/admin/backup', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'start_backup_admin'], 'permission_callback' => $admin, 'args' => $type_arg, ]); register_rest_route($ns, '/admin/progress', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'progress_admin'], 'permission_callback' => $admin, ]); register_rest_route($ns, '/admin/cancel', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'cancel'], 'permission_callback' => $admin, ]); register_rest_route($ns, '/admin/destinations/(?P[a-z0-9]+)/test', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'test_destination'], 'permission_callback' => $admin, ]); register_rest_route($ns, '/admin/size-report', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'size_report_get'], 'permission_callback' => $admin, ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'size_report_run'], 'permission_callback' => $admin, 'args' => ['restart' => ['type' => 'boolean', 'default' => false]], ], ]); register_rest_route($ns, '/admin/exclude', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'set_excluded'], 'permission_callback' => $admin, 'args' => [ 'kind' => ['type' => 'string', 'enum' => ['path', 'table'], 'required' => true], 'name' => ['type' => 'string', 'required' => true], 'skip' => ['type' => 'boolean', 'required' => true], ], ]); register_rest_route($ns, '/admin/loopback', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'test_loopback'], 'permission_callback' => $admin, ]); register_rest_route($ns, '/status', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'remote_status'], 'permission_callback' => $remote, ]); register_rest_route($ns, '/backups', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'remote_list'], 'permission_callback' => $remote, ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'remote_start'], 'permission_callback' => $remote, 'args' => $type_arg, ], ]); register_rest_route($ns, '/job', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'remote_job'], 'permission_callback' => $remote, ]); register_rest_route($ns, '/cron', [ 'methods' => [WP_REST_Server::READABLE, WP_REST_Server::CREATABLE], 'callback' => [$this, 'cron'], 'permission_callback' => [$this, 'cron_permission'], ]); } /* ── Permissions ─────────────────────────────────────────────── */ public function admin_permission(): bool { return current_user_can('manage_options'); } public function remote_permission(WP_REST_Request $request) { if (current_user_can('manage_options')) { return true; // Cookie + nonce, or an Application Password of an administrator. } $blocked = $this->check_failed_attempts(); if (is_wp_error($blocked)) { return $blocked; } $key = ''; $auth = (string)$request->get_header('authorization'); if (stripos($auth, 'Bearer ') === 0) { $key = trim(substr($auth, 7)); } if ($key === '') { $key = trim((string)$request->get_header('x_swpb_key')); } if ($key !== '' && $this->remote->verify_api_key($key)) { return true; } $this->record_failed_attempt(); return new WP_Error('swpb_unauthorized', __('Missing or invalid API key.', 'seljebackup'), ['status' => 401]); } public function cron_permission(WP_REST_Request $request) { $blocked = $this->check_failed_attempts(); if (is_wp_error($blocked)) { return $blocked; } if ($this->remote->verify_cron_key((string)$request->get_param('key'))) { return true; } $this->record_failed_attempt(); return new WP_Error('swpb_unauthorized', __('Invalid cron key.', 'seljebackup'), ['status' => 401]); } private function attempts_key(): string { $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : 'unknown'; return 'swpb_auth_fail_' . md5($ip); } private function check_failed_attempts() { if ((int)get_transient($this->attempts_key()) >= 10) { return new WP_Error('swpb_rate_limited', __('Too many failed attempts. Try again in 15 minutes.', 'seljebackup'), ['status' => 429]); } return true; } private function record_failed_attempt(): void { $key = $this->attempts_key(); set_transient($key, (int)get_transient($key) + 1, 15 * MINUTE_IN_SECONDS); } /* ── Admin UI ────────────────────────────────────────────────── */ public function start_backup_admin(WP_REST_Request $request) { return $this->start((string)$request->get_param('type'), 'manual'); } public function progress_admin(): WP_REST_Response { // If background requests are blocked on this host, the open admin page keeps the backup moving. $this->runner->maybe_resume(15, 'browser', 10); return new WP_REST_Response($this->runner->status()); } public function cancel(): WP_REST_Response { $this->runner->cancel(); return new WP_REST_Response($this->runner->status()); } public function test_destination(WP_REST_Request $request) { $dest = $this->destinations->get((string)$request['id']); if (!$dest) { return new WP_Error('swpb_not_found', __('Storage destination not found.', 'seljebackup'), ['status' => 404]); } try { $adapter = $this->destinations->adapter($dest); if (!$adapter->is_ready()) { throw new SWPB_Exception(esc_html($adapter::uses_oauth() ? __('Fill in the credentials, save, and click "Connect" first.', 'seljebackup') : __('Fill in all required fields and save first.', 'seljebackup'))); } return new WP_REST_Response(['ok' => true, 'message' => $adapter->test()]); } catch (Throwable $e) { return new WP_REST_Response(['ok' => false, 'message' => SWPB_Exception::text($e)]); } } public function size_report_get(): WP_REST_Response { return new WP_REST_Response(['report' => $this->size_report->report()]); } public function size_report_run(WP_REST_Request $request): WP_REST_Response { return new WP_REST_Response($this->size_report->run((bool)$request->get_param('restart'))); } public function set_excluded(WP_REST_Request $request) { try { $name = (string)$request->get_param('name'); $exclude = (bool)$request->get_param('skip'); if ($request->get_param('kind') === 'table') { $this->size_report->set_table_excluded($name, $exclude); } else { $this->size_report->set_path_excluded($name, $exclude); } return new WP_REST_Response(['ok' => true, 'report' => $this->size_report->report()]); } catch (SWPB_Exception $e) { return new WP_Error('swpb_invalid', SWPB_Exception::text($e), ['status' => 400]); } } public function test_loopback(): WP_REST_Response { $ok = $this->runner->test_loopback(); return new WP_REST_Response([ 'ok' => $ok, 'message' => $ok ? __('Background processing works. Backups continue even if you close this page.', 'seljebackup') : __('This server blocks requests to itself. Backups still work while this page is open, and scheduled backups continue via WP-Cron every 5 minutes. For faster scheduled backups, add the Cron URL (Remote access tab) to a cron service.', 'seljebackup'), ]); } /* ── Remote dashboard API ────────────────────────────────────── */ public function remote_status(): WP_REST_Response { $this->runner->maybe_resume(30, 'remote'); $status = $this->runner->status(); $latest = $this->registry->latest(); $destinations = []; foreach ($this->destinations->all() as $dest) { $ready = false; try { $ready = $this->destinations->adapter($dest)->is_ready(); } catch (Throwable $e) { $ready = false; } $destinations[] = [ 'id' => $dest['id'], 'name' => $dest['name'], 'type' => $dest['type'], 'enabled' => !empty($dest['enabled']), 'ready' => $ready, ]; } $next = $this->scheduler->next_run(); return new WP_REST_Response([ 'plugin' => 'seljebackup', 'version' => SWPB_PLUGIN_VERSION, 'site' => ['name' => get_bloginfo('name'), 'url' => home_url()], 'health' => $this->health($status, $latest), 'running' => $status['running'], 'job' => $status['job'], 'last_backup' => $latest ? $this->format_backup($latest) : null, 'schedule' => [ 'frequency' => $this->settings->get('schedule'), 'type' => $this->settings->get('schedule_type'), 'next_run' => $next > 0 ? gmdate('c', $next) : null, ], 'destinations' => $destinations, 'backups_count' => count($this->registry->all()), ]); } public function remote_list(): WP_REST_Response { return new WP_REST_Response(array_values(array_map([$this, 'format_backup'], $this->registry->all()))); } public function remote_start(WP_REST_Request $request) { return $this->start((string)$request->get_param('type'), 'remote'); } public function remote_job(): WP_REST_Response { $this->runner->maybe_resume(30, 'remote'); return new WP_REST_Response($this->runner->status()); } public function cron(): WP_REST_Response { $this->scheduler->maybe_run(); $this->runner->maybe_resume(30, 'cron-url'); $next = $this->scheduler->next_run(); return new WP_REST_Response([ 'ok' => true, 'running' => $this->runner->is_running(), 'next_run' => $next > 0 ? gmdate('c', $next) : null, ]); } /* ── Helpers ─────────────────────────────────────────────────── */ private function start(string $type, string $trigger) { try { $job = $this->runner->start($type, $trigger); return new WP_REST_Response(['started' => true, 'id' => $job['id'], 'status' => $this->runner->status()], 202); } catch (SWPB_Exception $e) { return new WP_Error('swpb_busy', SWPB_Exception::text($e), ['status' => 409]); } } public function format_backup(array $entry): array { $destinations = []; foreach ((array)($entry['remote'] ?? []) as $id => $remote) { $destinations[] = [ 'id' => (string)$id, 'name' => (string)($remote['name'] ?? ''), 'type' => (string)($remote['type'] ?? ''), 'ok' => empty($remote['error']), ]; } return [ 'id' => (string)$entry['id'], 'created' => gmdate('c', (int)$entry['created']), 'type' => (string)$entry['type'], 'trigger' => (string)$entry['trigger'], 'size' => array_sum(array_column((array)$entry['files'], 'size')), 'files' => array_values((array)$entry['files']), 'on_server' => !empty($entry['local']), 'destinations' => $destinations, 'warnings' => array_values((array)($entry['warnings'] ?? [])), 'duration' => (int)($entry['duration'] ?? 0), ]; } /** * One-glance health for dashboards: ok | warning | error. */ private function health(array $status, ?array $latest): array { $job = $status['job']; if ($job && $job['status'] === 'failed') { return ['status' => 'error', 'message' => (string)$job['error']]; } if (!$latest) { return ['status' => 'warning', 'message' => __('No backups yet.', 'seljebackup')]; } $periods = ['daily' => DAY_IN_SECONDS, 'weekly' => WEEK_IN_SECONDS, 'monthly' => MONTH_IN_SECONDS]; $schedule = (string)$this->settings->get('schedule'); if (isset($periods[$schedule]) && time() - (int)$latest['created'] > $periods[$schedule] * 1.5 + HOUR_IN_SECONDS) { return ['status' => 'warning', 'message' => __('The last backup is older than the schedule suggests.', 'seljebackup')]; } if (!empty($latest['warnings'])) { return ['status' => 'warning', 'message' => (string)reset($latest['warnings'])]; } return ['status' => 'ok', 'message' => __('Backups are up to date.', 'seljebackup')]; } }