///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]; } }