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:
182
includes/recovery-installer.php
Normal file
182
includes/recovery-installer.php
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user