Files
wp-healthcheck/includes/recovery-installer.php
Steve Hanlon 6dd050ea1d Rename to ATT Site Healthcheck (private prefix)
Wholesale rename to avoid clashes with generic 'site healthcheck'
plugin names on a target site:

  - Plugin Name:        'Site Healthcheck' → 'ATT Site Healthcheck'
  - Main file:          site-healthcheck.php → att-site-healthcheck.php
  - Plugin folder:      site-healthcheck → att-site-healthcheck
  - Admin menu slug:    site-healthcheck → att-site-healthcheck
  - Settings slug:      site-healthcheck-settings → att-site-healthcheck-settings
  - PHP class prefix:   WPH_ → ATT_HC_
  - Function prefix:    wph_ → att_hc_
  - Option / transient: wph_* → att_hc_*
  - Action/filter:      wph_* → att_hc_*
  - CSS class prefix:   wph- → att-hc-
  - Constants:          WPH_GITEA_* → ATT_HC_GITEA_*
  - Class file names:   class-wph-*.php → class-att-hc-*.php
  - Dev folder:         ~/dev/wp-healthcheck → ~/dev/att-site-healthcheck

Existing in-progress sessions on installs that had the old wph_session
option will not migrate — they were intended for dev use only and the
user has confirmed this is OK for the rename window.

Smoke-tested on testsite: classes load, 14 steps discovered, save/load
round-trip works, admin page renders with new att-hc- CSS classes.

Recovery plugin detection unchanged — that lives in wp-site-recovery
and continues to be detected by Name + Author header.
2026-06-12 11:11:42 +01:00

183 lines
8.8 KiB
PHP

<?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('ATT_HC_GITEA_HOST', 'https://git.example.com');
* define('ATT_HC_GITEA_OWNER', 'steve');
* define('ATT_HC_GITEA_REPO', 'site-recovery');
* define('ATT_HC_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 ATT_HC_Recovery_Installer {
private const OPT_HOST = 'att_hc_gitea_host';
private const OPT_OWNER = 'att_hc_gitea_owner';
private const OPT_REPO = 'att_hc_gitea_repo';
private const OPT_TOKEN = 'att_hc_gitea_token';
public static function config(): array {
return [
'host' => defined('ATT_HC_GITEA_HOST') ? (string) ATT_HC_GITEA_HOST : (string) get_option(self::OPT_HOST, ''),
'owner' => defined('ATT_HC_GITEA_OWNER') ? (string) ATT_HC_GITEA_OWNER : (string) get_option(self::OPT_OWNER, ''),
'repo' => defined('ATT_HC_GITEA_REPO') ? (string) ATT_HC_GITEA_REPO : (string) get_option(self::OPT_REPO, ''),
'token' => defined('ATT_HC_GITEA_TOKEN') ? (string) ATT_HC_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('ATT_HC_GITEA_HOST'),
'owner' => defined('ATT_HC_GITEA_OWNER'),
'repo' => defined('ATT_HC_GITEA_REPO'),
'token' => defined('ATT_HC_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('ATT_HC_Recovery_Bootstrap', 'plugin_file')) {
// Bust the static cache so we re-scan get_plugins().
$rc = new ReflectionClass('ATT_HC_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];
}
}