From 8ceb756404dfc8b641acb3f11b88e282f9be4b38 Mon Sep 17 00:00:00 2001 From: Steve Hanlon Date: Fri, 12 Jun 2026 09:43:55 +0100 Subject: [PATCH] Recovery plugin: folder-agnostic detection + one-click install from Gitea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection (recovery-bootstrap.php): - Match on plugin Name + Author instead of hard-coded folder slug, so the recovery plugin is found regardless of whether it was unpacked as site-recovery/, wp-site-recovery/, or anything else. One-click install (recovery-installer.php — new): - Pulls site-recovery from a private Gitea repo via the standard archive endpoint with token auth. - Ref resolution order: latest release → latest tag → main branch HEAD. Lets us tag pinned releases in Gitea later without changing code. - upgrader_source_selection filter forces the unpacked folder name to 'site-recovery' regardless of the gitea wrapper-folder suffix. - Config from constants in wp-config.php (WPH_GITEA_HOST/OWNER/REPO/TOKEN) beats DB option storage. Constants are visually locked in the settings UI so admins can see they're inherited. Settings page (hidden submenu, reachable at Tools → Site Healthcheck → 'Configure gitea source'): - Host / Owner / Repo / Token fields - Inline 'Install now' button when configured + plugin not installed Bootstrap status panel: - Shows the recovery plugin's actual file path when active - 'Install from gitea (latest)' button when configured + missing - 'Configure gitea source' button when not configured + missing Install handler shows a success message via transient on the main healthcheck page after a successful install + activate. --- includes/admin-page.php | 112 ++++++++++++++++++++ includes/recovery-bootstrap.php | 54 ++++++++-- includes/recovery-installer.php | 182 ++++++++++++++++++++++++++++++++ site-healthcheck.php | 1 + 4 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 includes/recovery-installer.php diff --git a/includes/admin-page.php b/includes/admin-page.php index fa0b265..368155b 100644 --- a/includes/admin-page.php +++ b/includes/admin-page.php @@ -11,6 +11,8 @@ add_action('admin_post_wph_refresh_checks', 'wph_handle_refresh_checks'); add_action('admin_post_wph_download_html', 'wph_handle_download_html'); add_action('admin_post_wph_email_report', 'wph_handle_email_report'); add_action('admin_post_wph_step_action', 'wph_handle_step_action'); +add_action('admin_post_wph_recovery_install', 'wph_handle_recovery_install'); +add_action('admin_post_wph_save_settings', 'wph_handle_save_settings'); add_action('admin_enqueue_scripts', 'wph_enqueue_assets'); function wph_register_menu(): void { @@ -21,6 +23,14 @@ function wph_register_menu(): void { 'site-healthcheck', 'wph_render_admin_page' ); + add_submenu_page( + null, // hidden — reachable via direct URL + 'Site Healthcheck Settings', + 'Site Healthcheck Settings', + 'manage_options', + 'site-healthcheck-settings', + 'wph_render_settings_page' + ); } function wph_enqueue_assets($hook): void { @@ -100,6 +110,11 @@ function wph_render_admin_page(): void { echo '
'; echo '

Site Healthcheck

'; + if ($msg = get_transient('wph_install_message')) { + delete_transient('wph_install_message'); + echo '

' . esc_html($msg) . '

'; + } + if (!$session) { wph_render_start_panel(); echo '
'; @@ -429,6 +444,103 @@ function wph_render_autocheck(WPH_Session $session, WPH_Step $step): void { +
+

Site Healthcheck — Settings

+

← Back to healthcheck

+ +

Saved.

+ +
+

Recovery plugin source (Gitea)

+

One-click install pulls site-recovery from a private Gitea repo. The token needs read access to the repo only — a deploy / read-only PAT is safer than a personal token.

+

Each field can be set via a constant in wp-config.php (then it takes precedence and the field below is locked).

+ +
+ + + + + + + + + + + + + + + + + + + +
+ > +

Set via WPH_GITEA_HOST constant.

+
+ > +

Set via WPH_GITEA_OWNER constant.

+
+ > +

Set via WPH_GITEA_REPO constant.

+
+ > +

Set via WPH_GITEA_TOKEN constant.

+

Stored in WP options. Use a read-only deploy token scoped to this repo if possible.

+
+

+
+ + +
+

Install now

+
+ + + +
+ +
+

✓ Site Recovery is already installed at .

+ +
+
+ (string) wp_unslash($_POST['host'] ?? ''), + 'owner' => (string) wp_unslash($_POST['owner'] ?? ''), + 'repo' => (string) wp_unslash($_POST['repo'] ?? ''), + 'token' => (string) wp_unslash($_POST['token'] ?? ''), + ]); + wp_safe_redirect(admin_url('tools.php?page=site-healthcheck-settings&wph_saved=1')); + exit; +} + +function wph_handle_recovery_install(): void { + if (!current_user_can('install_plugins') || !current_user_can('activate_plugins')) wp_die('Forbidden'); + check_admin_referer('wph_recovery_install'); + @set_time_limit(120); + $result = WPH_Recovery_Installer::install_and_activate(); + if (is_wp_error($result)) { + wp_die('Install failed: ' . esc_html($result->get_error_message()) . '

Back to settings

'); + } + set_transient('wph_install_message', sprintf('Site Recovery installed from %s "%s" and activated.', $result['ref']['type'], $result['ref']['ref']), 60); + wp_safe_redirect(admin_url('tools.php?page=site-healthcheck')); + exit; +} + function wph_handle_step_action(): void { if (!current_user_can('manage_options')) wp_die('Forbidden'); $step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : ''; diff --git a/includes/recovery-bootstrap.php b/includes/recovery-bootstrap.php index fb87e6e..97fd74a 100644 --- a/includes/recovery-bootstrap.php +++ b/includes/recovery-bootstrap.php @@ -11,21 +11,41 @@ if (!defined('ABSPATH')) exit; */ final class WPH_Recovery_Bootstrap { - public const RECOVERY_SLUG = 'site-recovery'; - public const RECOVERY_MAIN = 'site-recovery/site-recovery.php'; + /** + * Detect by plugin Name + Author rather than folder slug — the recovery + * plugin can be installed under any folder (site-recovery/, wp-site-recovery/, + * etc.) depending on how it was unpacked. Cache the result per request. + */ + private const NAME = 'Site Recovery'; + private const AUTHOR = 'Steve Hanlon'; - public static function is_installed(): bool { + /** Return the plugin_basename (folder/file.php) if installed, or null. */ + public static function plugin_file(): ?string { + static $cached; + if ($cached !== null) return $cached === '' ? null : $cached; if (!function_exists('get_plugins')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - return array_key_exists(self::RECOVERY_MAIN, get_plugins()); + foreach (get_plugins() as $file => $meta) { + if (($meta['Name'] ?? '') === self::NAME && strpos((string) ($meta['Author'] ?? ''), self::AUTHOR) !== false) { + return $cached = $file; + } + } + $cached = ''; + return null; + } + + public static function is_installed(): bool { + return self::plugin_file() !== null; } public static function is_active(): bool { + $file = self::plugin_file(); + if (!$file) return false; if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } - return is_plugin_active(self::RECOVERY_MAIN); + return is_plugin_active($file); } public static function recovery_admin_url(): string { @@ -34,23 +54,37 @@ final class WPH_Recovery_Bootstrap { /** * Render a status panel that the admin page calls from the "Before you start" - * section. Phase-1: report state + link to manage. Phase-3 may add a - * one-click installer from a private URL. + * section. Shows install state, links to recovery settings when active, and + * offers a one-click install from gitea when missing+configured. */ public static function render_status(): void { $installed = self::is_installed(); $active = $installed && self::is_active(); + $file = self::plugin_file(); + $configured = WPH_Recovery_Installer::is_configured(); ?>

Recovery plugin status

-

Site Recovery is installed and active. +

Site Recovery is installed and active . Open recovery settings to copy the URL + password into the client record.

-

Site Recovery is installed but inactive. Activate it before starting work.

+

Site Recovery is installed but inactive . Activate it before starting work.

Site Recovery is not installed. Install it before starting the healthcheck — it's the safety net while we work on the site.

-

Phase-1: install manually via Plugins → Add New → Upload Plugin. A private update channel for one-click install lands in hc-5ix.27.

+ +
+ + + +
+ Configure source + +

+ Configure gitea source + — or install manually via Plugins → Add New → Upload Plugin. +

+
///archive/.zip with Authorization: token + * → Plugin_Upgrader::install() with upgrader_source_selection filter renaming + * the unpacked folder to 'site-recovery' so the destination is consistent. + * → activate_plugin() against the freshly-installed plugin file. + */ +final class WPH_Recovery_Installer { + + private const OPT_HOST = 'wph_gitea_host'; + private const OPT_OWNER = 'wph_gitea_owner'; + private const OPT_REPO = 'wph_gitea_repo'; + private const OPT_TOKEN = 'wph_gitea_token'; + + public static function config(): array { + return [ + 'host' => defined('WPH_GITEA_HOST') ? (string) WPH_GITEA_HOST : (string) get_option(self::OPT_HOST, ''), + 'owner' => defined('WPH_GITEA_OWNER') ? (string) WPH_GITEA_OWNER : (string) get_option(self::OPT_OWNER, ''), + 'repo' => defined('WPH_GITEA_REPO') ? (string) WPH_GITEA_REPO : (string) get_option(self::OPT_REPO, ''), + 'token' => defined('WPH_GITEA_TOKEN') ? (string) WPH_GITEA_TOKEN : (string) get_option(self::OPT_TOKEN, ''), + ]; + } + + /** Returns whether each config field comes from a constant (true) or an option (false). */ + public static function config_origin(): array { + return [ + 'host' => defined('WPH_GITEA_HOST'), + 'owner' => defined('WPH_GITEA_OWNER'), + 'repo' => defined('WPH_GITEA_REPO'), + 'token' => defined('WPH_GITEA_TOKEN'), + ]; + } + + public static function is_configured(): bool { + $c = self::config(); + return $c['host'] && $c['owner'] && $c['repo'] && $c['token']; + } + + public static function save_options(array $input): void { + // Constants win — only persist fields not already constant-overridden. + $origin = self::config_origin(); + if (!$origin['host']) update_option(self::OPT_HOST, esc_url_raw((string) ($input['host'] ?? '')), false); + if (!$origin['owner']) update_option(self::OPT_OWNER, sanitize_text_field((string) ($input['owner'] ?? '')), false); + if (!$origin['repo']) update_option(self::OPT_REPO, sanitize_text_field((string) ($input['repo'] ?? '')), false); + if (!$origin['token']) update_option(self::OPT_TOKEN, sanitize_text_field((string) ($input['token'] ?? '')), false); + } + + /** Find the ref to install: release → tag → main. */ + public static function resolve_latest_ref() { + $c = self::config(); + if (!self::is_configured()) { + return new WP_Error('not_configured', 'Gitea host/owner/repo/token not configured.'); + } + $base = rtrim($c['host'], '/'); + $auth = ['Authorization' => 'token ' . $c['token'], 'Accept' => 'application/json']; + + $r = wp_remote_get("$base/api/v1/repos/{$c['owner']}/{$c['repo']}/releases/latest", ['timeout' => 10, 'headers' => $auth]); + if (!is_wp_error($r) && (int) wp_remote_retrieve_response_code($r) === 200) { + $body = json_decode((string) wp_remote_retrieve_body($r), true); + if (is_array($body) && !empty($body['tag_name'])) { + return ['type' => 'release', 'ref' => (string) $body['tag_name']]; + } + } + + $r = wp_remote_get("$base/api/v1/repos/{$c['owner']}/{$c['repo']}/tags?limit=1", ['timeout' => 10, 'headers' => $auth]); + if (!is_wp_error($r) && (int) wp_remote_retrieve_response_code($r) === 200) { + $body = json_decode((string) wp_remote_retrieve_body($r), true); + if (is_array($body) && !empty($body[0]['name'])) { + return ['type' => 'tag', 'ref' => (string) $body[0]['name']]; + } + } + + return ['type' => 'branch', 'ref' => 'main']; + } + + /** Run install + activate. Returns true on success or a WP_Error. */ + public static function install_and_activate() { + if (!self::is_configured()) { + return new WP_Error('not_configured', 'Gitea is not configured — fill in host, owner, repo and a read-only token.'); + } + + $ref_info = self::resolve_latest_ref(); + if (is_wp_error($ref_info)) return $ref_info; + $c = self::config(); + $url = rtrim($c['host'], '/') . "/{$c['owner']}/{$c['repo']}/archive/{$ref_info['ref']}.zip"; + + // Inject auth header on outbound requests to this host so download_url() succeeds. + $auth_filter = function ($args, $req_url) use ($c) { + if (strpos((string) $req_url, rtrim($c['host'], '/')) === 0) { + if (!isset($args['headers']) || !is_array($args['headers'])) $args['headers'] = []; + $args['headers']['Authorization'] = 'token ' . $c['token']; + } + return $args; + }; + add_filter('http_request_args', $auth_filter, 10, 2); + + // Force the unpacked folder name to 'site-recovery' regardless of the + // gitea archive's wrapper folder (which gets a - suffix). + $rename_filter = function ($source, $remote_source, $upgrader, $hook_extra) { + if (!is_string($source) || !is_dir($source)) return $source; + $name = basename(rtrim($source, '/\\')); + if ($name === 'site-recovery') return $source; + global $wp_filesystem; + $new = trailingslashit(dirname($source)) . 'site-recovery'; + if ($wp_filesystem->exists($new)) $wp_filesystem->delete($new, true); + $wp_filesystem->move($source, $new); + return $new; + }; + add_filter('upgrader_source_selection', $rename_filter, 10, 4); + + // Load WP's upgrader machinery. + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/misc.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + if (!class_exists('Automatic_Upgrader_Skin')) { + require_once ABSPATH . 'wp-admin/includes/class-automatic-upgrader-skin.php'; + } + + // Need a writable FS first. + $creds = request_filesystem_credentials('', '', false, false, null); + if (!$creds || !WP_Filesystem($creds)) { + remove_filter('http_request_args', $auth_filter, 10); + remove_filter('upgrader_source_selection', $rename_filter, 10); + return new WP_Error('fs', 'Could not initialise WP_Filesystem.'); + } + + $skin = new Automatic_Upgrader_Skin(); + $upgrader = new Plugin_Upgrader($skin); + $result = $upgrader->install($url); + + remove_filter('http_request_args', $auth_filter, 10); + remove_filter('upgrader_source_selection', $rename_filter, 10); + + if (is_wp_error($result)) return $result; + if ($result === false) { + $msgs = method_exists($skin, 'get_upgrade_messages') ? implode(' · ', $skin->get_upgrade_messages()) : 'unknown'; + return new WP_Error('install_failed', 'Install returned false. Upgrader messages: ' . $msgs); + } + + // Find the freshly-installed plugin and activate it. + if (method_exists('WPH_Recovery_Bootstrap', 'plugin_file')) { + // Bust the static cache so we re-scan get_plugins(). + $rc = new ReflectionClass('WPH_Recovery_Bootstrap'); + // No public reset; just call get_plugins() in our context — Bootstrap will re-scan + // because get_plugins() builds a fresh array on each call. + wp_cache_delete('plugins', 'plugins'); + } + if (function_exists('wp_clean_plugins_cache')) wp_clean_plugins_cache(); + if (!function_exists('get_plugins')) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + $plugin_file = null; + foreach (get_plugins() as $file => $meta) { + if (($meta['Name'] ?? '') === 'Site Recovery') { $plugin_file = $file; break; } + } + if (!$plugin_file) { + return new WP_Error('post_install', 'Install completed but cannot locate the Site Recovery plugin file.'); + } + $activate = activate_plugin($plugin_file); + if (is_wp_error($activate)) return $activate; + + return ['plugin_file' => $plugin_file, 'ref' => $ref_info]; + } +} diff --git a/site-healthcheck.php b/site-healthcheck.php index c807216..afe9445 100644 --- a/site-healthcheck.php +++ b/site-healthcheck.php @@ -24,6 +24,7 @@ require_once WPH_PLUGIN_DIR . 'includes/class-wph-step.php'; require_once WPH_PLUGIN_DIR . 'includes/class-wph-steps.php'; require_once WPH_PLUGIN_DIR . 'includes/class-wph-session.php'; require_once WPH_PLUGIN_DIR . 'includes/recovery-bootstrap.php'; +require_once WPH_PLUGIN_DIR . 'includes/recovery-installer.php'; require_once WPH_PLUGIN_DIR . 'includes/admin-page.php'; require_once WPH_PLUGIN_DIR . 'includes/report.php';