Recovery plugin: folder-agnostic detection + one-click install from Gitea

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.
This commit is contained in:
2026-06-12 09:43:55 +01:00
parent 79cb06e705
commit 8ceb756404
4 changed files with 339 additions and 10 deletions

View File

@@ -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_download_html', 'wph_handle_download_html');
add_action('admin_post_wph_email_report', 'wph_handle_email_report'); 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_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'); add_action('admin_enqueue_scripts', 'wph_enqueue_assets');
function wph_register_menu(): void { function wph_register_menu(): void {
@@ -21,6 +23,14 @@ function wph_register_menu(): void {
'site-healthcheck', 'site-healthcheck',
'wph_render_admin_page' '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 { function wph_enqueue_assets($hook): void {
@@ -100,6 +110,11 @@ function wph_render_admin_page(): void {
echo '<div class="wrap">'; echo '<div class="wrap">';
echo '<h1>Site Healthcheck</h1>'; echo '<h1>Site Healthcheck</h1>';
if ($msg = get_transient('wph_install_message')) {
delete_transient('wph_install_message');
echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($msg) . '</p></div>';
}
if (!$session) { if (!$session) {
wph_render_start_panel(); wph_render_start_panel();
echo '</div>'; echo '</div>';
@@ -429,6 +444,103 @@ function wph_render_autocheck(WPH_Session $session, WPH_Step $step): void {
<?php <?php
} }
function wph_render_settings_page(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$c = WPH_Recovery_Installer::config();
$o = WPH_Recovery_Installer::config_origin();
$saved = isset($_GET['wph_saved']);
?>
<div class="wrap">
<h1>Site Healthcheck — Settings</h1>
<p><a href="<?php echo esc_url(admin_url('tools.php?page=site-healthcheck')); ?>">&larr; Back to healthcheck</a></p>
<?php if ($saved): ?><div class="notice notice-success is-dismissible"><p>Saved.</p></div><?php endif; ?>
<div class="wph-card">
<h2>Recovery plugin source (Gitea)</h2>
<p>One-click install pulls <code>site-recovery</code> 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.</p>
<p>Each field can be set via a constant in <code>wp-config.php</code> (then it takes precedence and the field below is locked).</p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('wph_save_settings'); ?>
<input type="hidden" name="action" value="wph_save_settings">
<table class="form-table">
<tr>
<th><label for="wph-host">Gitea host</label></th>
<td>
<input id="wph-host" type="url" name="host" value="<?php echo esc_attr($c['host']); ?>" class="regular-text" placeholder="https://git.example.com" <?php disabled($o['host']); ?>>
<?php if ($o['host']): ?><p class="description">Set via <code>WPH_GITEA_HOST</code> constant.</p><?php endif; ?>
</td>
</tr>
<tr>
<th><label for="wph-owner">Owner</label></th>
<td>
<input id="wph-owner" type="text" name="owner" value="<?php echo esc_attr($c['owner']); ?>" class="regular-text" placeholder="steve" <?php disabled($o['owner']); ?>>
<?php if ($o['owner']): ?><p class="description">Set via <code>WPH_GITEA_OWNER</code> constant.</p><?php endif; ?>
</td>
</tr>
<tr>
<th><label for="wph-repo">Repo</label></th>
<td>
<input id="wph-repo" type="text" name="repo" value="<?php echo esc_attr($c['repo']); ?>" class="regular-text" placeholder="site-recovery" <?php disabled($o['repo']); ?>>
<?php if ($o['repo']): ?><p class="description">Set via <code>WPH_GITEA_REPO</code> constant.</p><?php endif; ?>
</td>
</tr>
<tr>
<th><label for="wph-token">Token</label></th>
<td>
<input id="wph-token" type="password" name="token" value="<?php echo esc_attr($c['token']); ?>" class="regular-text" autocomplete="new-password" <?php disabled($o['token']); ?>>
<?php if ($o['token']): ?><p class="description">Set via <code>WPH_GITEA_TOKEN</code> constant.</p>
<?php else: ?><p class="description">Stored in WP options. Use a read-only deploy token scoped to this repo if possible.</p><?php endif; ?>
</td>
</tr>
</table>
<p><button class="button button-primary">Save settings</button></p>
</form>
<?php if (WPH_Recovery_Installer::is_configured() && !WPH_Recovery_Bootstrap::is_installed()): ?>
<hr>
<h3>Install now</h3>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Download and install Site Recovery now?');">
<?php wp_nonce_field('wph_recovery_install'); ?>
<input type="hidden" name="action" value="wph_recovery_install">
<button class="button button-primary">Install from gitea (latest)</button>
</form>
<?php elseif (WPH_Recovery_Bootstrap::is_installed()): ?>
<hr>
<p class="wph-ok">✓ Site Recovery is already installed at <code><?php echo esc_html((string) WPH_Recovery_Bootstrap::plugin_file()); ?></code>.</p>
<?php endif; ?>
</div>
</div>
<?php
}
function wph_handle_save_settings(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_save_settings');
WPH_Recovery_Installer::save_options([
'host' => (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()) . ' <p><a href="' . esc_url(admin_url('tools.php?page=site-healthcheck-settings')) . '">Back to settings</a></p>');
}
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 { function wph_handle_step_action(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden'); if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : ''; $step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';

View File

@@ -11,21 +11,41 @@ if (!defined('ABSPATH')) exit;
*/ */
final class WPH_Recovery_Bootstrap { 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')) { if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php'; 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 { public static function is_active(): bool {
$file = self::plugin_file();
if (!$file) return false;
if (!function_exists('is_plugin_active')) { if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php'; 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 { 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" * 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 * section. Shows install state, links to recovery settings when active, and
* one-click installer from a private URL. * offers a one-click install from gitea when missing+configured.
*/ */
public static function render_status(): void { public static function render_status(): void {
$installed = self::is_installed(); $installed = self::is_installed();
$active = $installed && self::is_active(); $active = $installed && self::is_active();
$file = self::plugin_file();
$configured = WPH_Recovery_Installer::is_configured();
?> ?>
<div class="wph-bootstrap-panel"> <div class="wph-bootstrap-panel">
<h3>Recovery plugin status</h3> <h3>Recovery plugin status</h3>
<?php if ($active): ?> <?php if ($active): ?>
<p class="wph-ok">✓ <strong>Site Recovery</strong> is installed and active. <p class="wph-ok">✓ <strong>Site Recovery</strong> is installed and active <code><?php echo esc_html((string) $file); ?></code>.
<a href="<?php echo esc_url(self::recovery_admin_url()); ?>">Open recovery settings</a> to copy the URL + password into the client record.</p> <a href="<?php echo esc_url(self::recovery_admin_url()); ?>">Open recovery settings</a> to copy the URL + password into the client record.</p>
<?php elseif ($installed): ?> <?php elseif ($installed): ?>
<p class="wph-warn">⚠ <strong>Site Recovery</strong> is installed but inactive. <a href="<?php echo esc_url(admin_url('plugins.php')); ?>">Activate it</a> before starting work.</p> <p class="wph-warn">⚠ <strong>Site Recovery</strong> is installed but inactive <code><?php echo esc_html((string) $file); ?></code>. <a href="<?php echo esc_url(admin_url('plugins.php')); ?>">Activate it</a> before starting work.</p>
<?php else: ?> <?php else: ?>
<p class="wph-bad">✗ <strong>Site Recovery</strong> is not installed. Install it before starting the healthcheck — it's the safety net while we work on the site.</p> <p class="wph-bad">✗ <strong>Site Recovery</strong> is not installed. Install it before starting the healthcheck — it's the safety net while we work on the site.</p>
<p class="description">Phase-1: install manually via <em>Plugins → Add New → Upload Plugin</em>. A private update channel for one-click install lands in hc-5ix.27.</p> <?php if ($configured): ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline" onsubmit="return confirm('Download and install Site Recovery from gitea?');">
<?php wp_nonce_field('wph_recovery_install'); ?>
<input type="hidden" name="action" value="wph_recovery_install">
<button class="button button-primary">Install from gitea (latest)</button>
</form>
<a href="<?php echo esc_url(admin_url('tools.php?page=site-healthcheck-settings')); ?>" class="button">Configure source</a>
<?php else: ?>
<p>
<a href="<?php echo esc_url(admin_url('tools.php?page=site-healthcheck-settings')); ?>" class="button button-primary">Configure gitea source</a>
<em>— or install manually via Plugins → Add New → Upload Plugin.</em>
</p>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php <?php

View File

@@ -0,0 +1,182 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* One-click install of wp-site-recovery from a private Gitea repo.
*
* Config sources (in order — constants beat options):
* define('WPH_GITEA_HOST', 'https://git.example.com');
* define('WPH_GITEA_OWNER', 'steve');
* define('WPH_GITEA_REPO', 'site-recovery');
* define('WPH_GITEA_TOKEN', 'xxx'); // read-only PAT, scoped to this repo
*
* …or set the same values via the settings panel (stored in WP options).
*
* Ref resolution (in order):
* 1. Latest release — /api/v1/repos/:o/:r/releases/latest → tag_name
* 2. Latest tag — /api/v1/repos/:o/:r/tags?limit=1 → name
* 3. main branch HEAD
*
* Install path:
* GET https://<host>/<owner>/<repo>/archive/<ref>.zip with Authorization: token <pat>
* → 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 -<ref> 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];
}
}

View File

@@ -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-steps.php';
require_once WPH_PLUGIN_DIR . 'includes/class-wph-session.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-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/admin-page.php';
require_once WPH_PLUGIN_DIR . 'includes/report.php'; require_once WPH_PLUGIN_DIR . 'includes/report.php';