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 '
';
+ }
+
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
+
+
+
+
+
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).
+
+
+
+
+
+
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';
]