settings = $settings; $this->fs = $fs; $this->registry = $registry; $this->destinations = $destinations; $this->remote = $remote; $this->runner = $runner; $this->scheduler = $scheduler; $this->preflight = $preflight; } /** * Plugin version plus file time, so browsers fetch an asset again whenever it changes. */ private static function asset_version(string $relative): string { $path = SWPB_PLUGIN_DIR . $relative; return SWPB_PLUGIN_VERSION . (file_exists($path) ? '.' . filemtime($path) : ''); } public function enqueue_assets(string $hook): void { if ($hook !== 'toplevel_page_' . SWPB_ADMIN_SLUG) { return; } wp_enqueue_style('swpb-admin', SWPB_PLUGIN_URL . 'assets/admin.css', [], self::asset_version('assets/admin.css')); wp_enqueue_script('swpb-admin', SWPB_PLUGIN_URL . 'assets/admin.js', [], self::asset_version('assets/admin.js'), true); wp_localize_script('swpb-admin', 'swpbAdmin', [ 'restUrl' => esc_url_raw(rest_url(SWPB_REST_NAMESPACE)), 'nonce' => wp_create_nonce('wp_rest'), 'backupsUrl' => esc_url_raw($this->url('backups')), 'strings' => [ 'copy' => __('Copy', 'seljebackup'), 'copied' => __('Copied', 'seljebackup'), 'starting' => __('Starting backup…', 'seljebackup'), 'testing' => __('Testing…', 'seljebackup'), 'cancelConfirm' => __('Cancel the running backup?', 'seljebackup'), 'done' => __('Backup finished successfully.', 'seljebackup'), 'doneWarnings' => __('Backup finished with warnings:', 'seljebackup'), 'failed' => __('Backup failed:', 'seljebackup'), 'cancelled' => __('Backup cancelled.', 'seljebackup'), 'viewBackups' => __('View backups', 'seljebackup'), 'requestFailed' => __('Request failed. Please reload the page and try again.', 'seljebackup'), 'leaveWarning' => __('Your host blocks background processing, so the backup only continues while this page is open.', 'seljebackup'), 'pausing' => __('(short pause — low-impact mode)', 'seljebackup'), ], 'space' => [ /* translators: 1: number of files, 2: size */ 'analyzing' => __('Analyzing… %1$s files, %2$s so far', 'seljebackup'), /* translators: %s: date and time */ 'analyzed' => __('Last analyzed: %s', 'seljebackup'), 'reanalyze' => __('Analyze again', 'seljebackup'), 'siteSize' => __('Site folder', 'seljebackup'), 'inBackup' => __('Goes into the files backup', 'seljebackup'), 'beforeCompression' => __('before compression', 'seljebackup'), 'database' => __('Database', 'seljebackup'), /* translators: %s: number of files */ 'files' => __('%s files', 'seljebackup'), 'oneFile' => __('1 file', 'seljebackup'), 'tips' => __('Tips', 'seljebackup'), 'folders' => __('Folders', 'seljebackup'), 'largestFiles' => __('Largest files', 'seljebackup'), 'tables' => __('Largest database tables', 'seljebackup'), /* translators: %s: number of rows */ 'rows' => __('%s rows', 'seljebackup'), 'otherFiles' => __('(files directly in this folder)', 'seljebackup'), 'skip' => __('Skip in backups', 'seljebackup'), 'include' => __('Include again', 'seljebackup'), 'included' => __('In backups', 'seljebackup'), 'reasons' => [ 'self' => __('seljeBackup\'s own backups — never included', 'seljebackup'), 'default' => __('Skipped automatically (cache / other backups / logs)', 'seljebackup'), 'vcs' => __('Skipped (developer folder)', 'seljebackup'), 'custom' => __('Skipped by you', 'seljebackup'), 'core' => __('WordPress core — not needed, it can be downloaded again', 'seljebackup'), 'scope' => __('Not included (outside wp-content — see Settings → Files)', 'seljebackup'), 'other_app' => __('Not included (not a table of this site)', 'seljebackup'), 'coreTable' => __('Core table — always included', 'seljebackup'), ], 'confirmUploads' => __('Your media library (uploads) usually cannot be recreated. Skip it in backups anyway?', 'seljebackup'), 'confirmExclude' => __('Skip this in future backups?', 'seljebackup'), 'failed' => __('The analysis failed. Please try again.', 'seljebackup'), 'toggle' => __('Show subfolders', 'seljebackup'), ], ]); } public function url(string $tab = 'dashboard', array $args = []): string { return add_query_arg(array_merge(['page' => SWPB_ADMIN_SLUG, 'tab' => $tab], $args), admin_url('admin.php')); } /* ── Flash notices ───────────────────────────────────────────── */ private function flash(string $message, string $type = 'success'): void { set_transient('swpb_notice_' . get_current_user_id(), ['message' => $message, 'type' => $type], 120); } private function take_flash(): ?array { $key = 'swpb_notice_' . get_current_user_id(); $notice = get_transient($key); if (is_array($notice)) { delete_transient($key); return $notice; } return null; } private function redirect(string $tab, array $args = []): void { wp_safe_redirect($this->url($tab, $args)); exit; } /* ── Form handling ───────────────────────────────────────────── */ public function handle_postbacks(): void { if (!isset($_POST['swpb_action']) || !current_user_can('manage_options')) { return; } check_admin_referer(self::NONCE); $post = wp_unslash($_POST); $action = sanitize_key((string)$post['swpb_action']); try { switch ($action) { case 'quick_setup': $this->settings->save([ 'schedule' => (string)($post['schedule'] ?? 'daily'), 'schedule_type' => (string)($post['schedule_type'] ?? 'full'), 'setup_done' => true, ]); $this->scheduler->reschedule(); $where = sanitize_key((string)($post['where'] ?? 'local')); if ($where !== 'local' && SWPB_Destinations::class_for($where)) { $this->flash(__('Great! Now connect your cloud storage below.', 'seljebackup')); $this->redirect('storage', ['add' => $where]); } $this->flash(__('All set. Backups will run automatically — you can also click "Back up now" at any time.', 'seljebackup')); $this->redirect('dashboard'); break; case 'save_settings': $input = $post['swpb'] ?? []; $this->settings->save(is_array($input) ? $input : []); $this->scheduler->reschedule(); $this->flash(__('Settings saved.', 'seljebackup')); $this->redirect('settings'); break; case 'save_destination': $dest = $this->destinations->save_from_input( sanitize_key((string)($post['dest_id'] ?? '')), sanitize_key((string)($post['dest_type'] ?? '')), $post ); $adapter = $this->destinations->adapter($dest); if ($adapter instanceof SWPB_Storage_OAuth && !$adapter->is_connected()) { $this->flash(__('Saved. Now click "Connect" to give access to your account.', 'seljebackup')); } else { $this->flash(__('Storage saved. Click "Test connection" to check it.', 'seljebackup')); } $this->redirect('storage', ['edit' => $dest['id']]); break; case 'delete_destination': $this->destinations->delete(sanitize_key((string)($post['dest_id'] ?? ''))); $this->flash(__('Storage removed. Backups already stored there were not deleted.', 'seljebackup'), 'warning'); $this->redirect('storage'); break; case 'connect_destination': $this->start_oauth(sanitize_key((string)($post['dest_id'] ?? ''))); break; case 'disconnect_destination': $dest = $this->destinations->get(sanitize_key((string)($post['dest_id'] ?? ''))); if ($dest) { $adapter = $this->destinations->adapter($dest); if ($adapter instanceof SWPB_Storage_OAuth) { $adapter->disconnect(); } } $this->flash(__('Disconnected.', 'seljebackup'), 'warning'); $this->redirect('storage'); break; case 'delete_backup': $this->delete_backup(sanitize_key((string)($post['backup_id'] ?? '')), !empty($post['delete_remote'])); $this->redirect('backups'); break; case 'generate_api_key': $key = $this->remote->generate_api_key(); set_transient('swpb_new_api_key_' . get_current_user_id(), $key, 10 * MINUTE_IN_SECONDS); $this->flash(__('New API key created. Copy it now — it will not be shown again.', 'seljebackup')); $this->redirect('remote'); break; case 'revoke_api_key': $this->remote->revoke_api_key(); $this->flash(__('API key revoked. Connected dashboards can no longer access this site.', 'seljebackup'), 'warning'); $this->redirect('remote'); break; case 'regenerate_cron_key': $this->remote->regenerate_cron_key(); $this->flash(__('New cron URL created. Update it in your cron service.', 'seljebackup'), 'warning'); $this->redirect('remote'); break; case 'save_webhook': $this->remote->save_webhook((string)($post['webhook_url'] ?? '')); $this->flash(__('Webhook saved.', 'seljebackup')); $this->redirect('remote'); break; } } catch (Throwable $e) { $this->flash(SWPB_Exception::text($e), 'error'); $tab = sanitize_key((string)($post['swpb_tab'] ?? 'dashboard')); $this->redirect(in_array($tab, self::TABS, true) ? $tab : 'dashboard'); } } private function delete_backup(string $id, bool $delete_remote): void { $entry = $this->registry->get($id); if (!$entry) { return; } if ($this->runner->is_running() && ($this->runner->current()['id'] ?? '') === $id) { throw new SWPB_Exception(esc_html(__('This backup is still running.', 'seljebackup'))); } SWPB_Retention::delete_local_files($this->fs, $entry); $errors = []; if ($delete_remote) { foreach ((array)($entry['remote'] ?? []) as $dest_id => $remote) { $dest = $this->destinations->get((string)$dest_id); if (!$dest) { continue; } try { $adapter = $this->destinations->adapter($dest); foreach ((array)($remote['refs'] ?? []) as $ref) { $adapter->delete((array)$ref); } } catch (Throwable $e) { $errors[] = (string)$dest['name'] . ': ' . SWPB_Exception::text($e); } } } if (!empty($entry['log'])) { $log = $this->fs->backup_file_path((string)$entry['log']); if ($log) { wp_delete_file($log); } } $this->registry->delete($id); if ($errors) { $this->flash(__('Backup deleted, but some cloud copies could not be removed: ', 'seljebackup') . implode('; ', $errors), 'warning'); } else { $this->flash(__('Backup deleted.', 'seljebackup')); } } /* ── OAuth ───────────────────────────────────────────────────── */ private function start_oauth(string $dest_id): void { $dest = $this->destinations->get($dest_id); $adapter = $dest ? $this->destinations->adapter($dest) : null; if (!$adapter instanceof SWPB_Storage_OAuth) { throw new SWPB_Exception(esc_html(__('This storage type does not use "Connect".', 'seljebackup'))); } if (!$adapter->has_credentials()) { throw new SWPB_Exception(esc_html(__('Fill in and save the app credentials first.', 'seljebackup'))); } $state = 'swpb' . wp_generate_password(32, false, false); $verifier = wp_generate_password(64, false, false); set_transient('swpb_oauth_' . $state, [ 'dest' => $dest_id, 'verifier' => $verifier, 'user' => get_current_user_id(), ], 20 * MINUTE_IN_SECONDS); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect to the storage provider's login page. wp_redirect($adapter->authorize_url($state, $verifier)); exit; } /** * The provider sends the user back to /wp-admin/?code=…&state=swpb… (or ?error=…). */ public function handle_oauth_callback(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the random "state" is the CSRF token here. $state = isset($_GET['state']) ? sanitize_text_field(wp_unslash($_GET['state'])) : ''; if (strpos($state, 'swpb') !== 0 || !current_user_can('manage_options')) { return; } $data = get_transient('swpb_oauth_' . $state); delete_transient('swpb_oauth_' . $state); if (!is_array($data) || (int)$data['user'] !== get_current_user_id()) { $this->flash(__('The connection attempt expired or was invalid. Please click "Connect" again.', 'seljebackup'), 'error'); $this->redirect('storage'); } if (isset($_GET['error'])) { $error = sanitize_text_field(wp_unslash($_GET['error_description'] ?? $_GET['error'])); /* translators: %s: error from the storage provider */ $this->flash(sprintf(__('The connection was not completed: %s', 'seljebackup'), $error), 'error'); $this->redirect('storage', ['edit' => $data['dest']]); } $code = isset($_GET['code']) ? sanitize_text_field(wp_unslash($_GET['code'])) : ''; // phpcs:enable $dest = $this->destinations->get((string)$data['dest']); if (!$dest || $code === '') { $this->redirect('storage'); } try { $adapter = $this->destinations->adapter($dest); if (!$adapter instanceof SWPB_Storage_OAuth) { $this->redirect('storage'); } $adapter->exchange_code($code, (string)$data['verifier']); $this->flash(__('Connected! Click "Test connection" to check that uploads work.', 'seljebackup')); } catch (Throwable $e) { $this->flash(SWPB_Exception::text($e), 'error'); } $this->redirect('storage', ['edit' => $dest['id']]); } /* ── Downloads ───────────────────────────────────────────────── */ public function handle_download(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You are not allowed to download backups.', 'seljebackup'), '', ['response' => 403]); } $id = isset($_GET['backup']) ? sanitize_key(wp_unslash($_GET['backup'])) : ''; $name = isset($_GET['file']) ? sanitize_file_name(wp_unslash($_GET['file'])) : ''; check_admin_referer('swpb_download_' . $id); $entry = $this->registry->get($id); $allowed = $entry ? array_merge(array_column((array)$entry['files'], 'name'), [(string)($entry['log'] ?? '')]) : []; $path = in_array($name, $allowed, true) ? $this->fs->backup_file_path($name) : null; if (!$path) { wp_die(esc_html__('This backup file is no longer on the server. It may have been removed by the "keep" setting; download it from your cloud storage instead.', 'seljebackup'), '', ['response' => 404]); } while (ob_get_level() > 0) { ob_end_clean(); } nocache_headers(); $is_log = substr($name, -4) === '.txt'; header('Content-Type: ' . ($is_log ? 'text/plain; charset=utf-8' : 'application/octet-stream')); header('Content-Disposition: ' . ($is_log ? 'inline' : 'attachment') . '; filename="' . $name . '"'); header('Content-Length: ' . (string)filesize($path)); header('X-Content-Type-Options: nosniff'); if (function_exists('set_time_limit')) { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- large backup downloads must not be cut off. @set_time_limit(0); } // phpcs:disable WordPress.WP.AlternativeFunctions -- streams a possibly multi-gigabyte backup in 1 MB chunks; WP_Filesystem would load it into memory. $handle = fopen($path, 'rb'); if ($handle) { while (!feof($handle)) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary file download. echo fread($handle, 1048576); flush(); } fclose($handle); } // phpcs:enable exit; } public function download_url(string $backup_id, string $file): string { return wp_nonce_url( add_query_arg(['action' => 'swpb_download', 'backup' => $backup_id, 'file' => $file], admin_url('admin-post.php')), 'swpb_download_' . $backup_id ); } /* ── Page ────────────────────────────────────────────────────── */ public function render_page(): void { if (!current_user_can('manage_options')) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only navigation parameters. $swpb_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'dashboard'; if (!in_array($swpb_tab, self::TABS, true)) { $swpb_tab = 'dashboard'; } $swpb_edit_id = isset($_GET['edit']) ? sanitize_key(wp_unslash($_GET['edit'])) : ''; $swpb_add_type = isset($_GET['add']) ? sanitize_key(wp_unslash($_GET['add'])) : ''; // phpcs:enable $swpb_view = [ 'admin' => $this, 'tab' => $swpb_tab, 'tabs' => [ 'dashboard' => __('Dashboard', 'seljebackup'), 'backups' => __('Backups', 'seljebackup'), 'storage' => __('Storage', 'seljebackup'), 'settings' => __('Settings', 'seljebackup'), 'remote' => __('Remote access', 'seljebackup'), ], 'notice' => $this->take_flash(), 'nonce_action' => self::NONCE, 'settings' => $this->settings->all(), 'registry' => $this->registry, 'destinations' => $this->destinations, 'remote' => $this->remote, 'runner' => $this->runner, 'scheduler' => $this->scheduler, 'preflight' => $this->preflight, 'fs' => $this->fs, 'edit_id' => $swpb_edit_id, 'add_type' => $swpb_add_type, 'new_api_key' => '', ]; if ($swpb_tab === 'remote') { $key_transient = 'swpb_new_api_key_' . get_current_user_id(); $swpb_view['new_api_key'] = (string)get_transient($key_transient); delete_transient($key_transient); } include SWPB_PLUGIN_DIR . 'templates/admin-page.php'; } public function nonce_field(string $tab): void { wp_nonce_field(self::NONCE); echo ''; } }