Files
wp-healthcheck/includes/admin-page.php
Steve Hanlon 8ceb756404 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.
2026-06-12 09:43:55 +01:00

641 lines
32 KiB
PHP

<?php
if (!defined('ABSPATH')) exit;
add_action('admin_menu', 'wph_register_menu');
add_action('admin_post_wph_start', 'wph_handle_start');
add_action('admin_post_wph_save_step', 'wph_handle_save_step');
add_action('admin_post_wph_finish', 'wph_handle_finish');
add_action('admin_post_wph_discard', 'wph_handle_discard');
add_action('admin_post_wph_download_report', 'wph_handle_download_report');
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 {
add_management_page(
'Site Healthcheck',
'Site Healthcheck',
'manage_options',
'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 {
if ($hook !== 'tools_page_site-healthcheck') return;
wp_register_style('wph-admin', false);
wp_enqueue_style('wph-admin');
wp_add_inline_style('wph-admin', wph_inline_css());
}
function wph_inline_css(): string {
return '
.wph-card { background:#fff; border:1px solid #c3c4c7; border-radius:6px; padding:1rem 1.25rem; margin-bottom:1rem; }
.wph-card h2 { margin-top:0; }
.wph-step-status { display:inline-block; padding:.1rem .55rem; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.04em; }
.wph-status-not_started { background:#f0f0f1; color:#646970; }
.wph-status-done { background:#def7e3; color:#155724; }
.wph-status-skipped { background:#fff3cd; color:#856404; }
.wph-status-blocked { background:#fbeae8; color:#721c24; }
.wph-status-n_a { background:#e2e3e5; color:#41464b; }
.wph-step { padding:1rem 1.25rem; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; background:#fff; }
.wph-step header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:.5rem; }
.wph-step header h2 { margin:0; font-size:1.1rem; }
.wph-sub-items { margin:.5rem 0 .75rem 1.25rem; padding:0; }
.wph-sub-items li { margin:.15rem 0; }
.wph-watch-outs { background:#fff8e1; border-left:3px solid #f5b800; padding:.4rem .8rem; margin:.5rem 0; font-size:.92em; }
.wph-watch-outs strong { display:block; margin-bottom:.2rem; }
.wph-escalation { background:#fbeae8; border-left:3px solid #c0392b; padding:.4rem .8rem; margin:.5rem 0; font-weight:500; }
.wph-step textarea { width:100%; min-height:60px; }
.wph-progress { font-weight:600; }
.wph-ok { color:#155724; }
.wph-warn { color:#856404; }
.wph-bad { color:#721c24; }
.wph-bootstrap-panel { padding:.6rem 1rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; }
.wph-bootstrap-panel h3 { margin:0 0 .35rem; font-size:1rem; }
.wph-actions { display:flex; gap:.5rem; align-items:center; margin-top:.4rem; }
.wph-autocheck { background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; padding:.6rem 1rem; margin:.6rem 0; }
.wph-autocheck header { display:flex; justify-content:space-between; align-items:center; margin:0 0 .4rem; }
.wph-autocheck header h3 { margin:0; font-size:.95rem; }
.wph-autocheck table { width:100%; border-collapse:collapse; }
.wph-autocheck td { padding:.25rem .4rem; vertical-align:top; border-bottom:1px solid #f0f0f1; }
.wph-autocheck tr:last-child td { border-bottom:0; }
.wph-autocheck .lvl { width:1.4rem; text-align:center; font-weight:600; }
.wph-autocheck .lvl-ok { color:#1a8917; }
.wph-autocheck .lvl-warn { color:#b07a00; }
.wph-autocheck .lvl-bad { color:#c0392b; }
.wph-autocheck .lvl-info { color:#646970; }
.wph-autocheck .label { font-weight:600; }
.wph-autocheck .value { color:#1d1d1f; }
.wph-autocheck .detail { color:#646970; font-size:.9em; }
.wph-checked-at { color:#646970; font-size:.85em; }
.wph-layout { display:grid; grid-template-columns: 220px 1fr; gap:1rem; }
.wph-sidebar { position:sticky; top:36px; align-self:start; max-height: calc(100vh - 60px); overflow:auto; }
.wph-sidebar .wph-card { padding:.75rem 1rem; }
.wph-sidebar h3 { margin:0 0 .4rem; font-size:.9rem; text-transform:uppercase; letter-spacing:.04em; color:#646970; }
.wph-sidebar ol { margin:0; padding:0; list-style:none; }
.wph-sidebar li { padding:.18rem 0; }
.wph-sidebar a { text-decoration:none; }
.wph-sidebar .dot { display:inline-block; width:.6rem; height:.6rem; border-radius:50%; margin-right:.4rem; background:#dcdcde; vertical-align:middle; }
.wph-sidebar .dot-done { background:#1a8917; }
.wph-sidebar .dot-skipped { background:#b07a00; }
.wph-sidebar .dot-blocked { background:#c0392b; }
.wph-sidebar .dot-n_a { background:#646970; }
.wph-diff { background:#eef4fb; border:1px solid #cfe0f3; padding:.6rem 1rem; border-radius:6px; margin:.5rem 0; font-size:.9em; }
.wph-diff strong { display:inline-block; margin-right:.4rem; }
.wph-diff .delta-new { color:#c0392b; }
.wph-diff .delta-resolved { color:#1a8917; }
.wph-diff .delta-changed { color:#b07a00; }
@media (max-width: 980px) { .wph-layout { grid-template-columns: 1fr; } .wph-sidebar { position: static; max-height: none; } }
';
}
function wph_render_admin_page(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$session = WPH_Session::current();
echo '<div class="wrap">';
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) {
wph_render_start_panel();
echo '</div>';
return;
}
if ($session->is_finished()) {
wph_render_finished_panel($session);
echo '</div>';
return;
}
wph_render_active_session($session);
echo '</div>';
}
function wph_render_start_panel(): void {
?>
<div class="wph-card">
<h2>Start a healthcheck</h2>
<p>This will create a new in-progress session for <code><?php echo esc_html(get_site_url()); ?></code>. One session per site at a time.</p>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<?php wp_nonce_field('wph_start'); ?>
<input type="hidden" name="action" value="wph_start">
<button class="button button-primary">Start new healthcheck</button>
</form>
</div>
<?php
WPH_Recovery_Bootstrap::render_status();
}
function wph_render_active_session(WPH_Session $session): void {
$progress = $session->progress();
$tech = get_userdata($session->technician_id());
?>
<div class="wph-card">
<p>
<strong>Session:</strong> <code><?php echo esc_html($session->id()); ?></code> ·
<strong>Started:</strong> <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> ·
<strong>Technician:</strong> <?php echo esc_html($tech ? $tech->display_name : '#' . $session->technician_id()); ?> ·
<span class="wph-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
</p>
<p>WP <code><?php echo esc_html($session->wp_version()); ?></code> · PHP <code><?php echo esc_html($session->php_version()); ?></code> · Site <code><?php echo esc_html($session->site_url()); ?></code></p>
<div class="wph-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Mark this healthcheck as finished?');" style="display:inline">
<?php wp_nonce_field('wph_finish'); ?>
<input type="hidden" name="action" value="wph_finish">
<button class="button button-primary">Finish &amp; generate report</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this in-progress session? All notes will be lost.');" style="display:inline">
<?php wp_nonce_field('wph_discard'); ?>
<input type="hidden" name="action" value="wph_discard">
<button class="button button-link-delete">Discard</button>
</form>
</div>
</div>
<?php
WPH_Recovery_Bootstrap::render_status();
wph_render_blocked_summary($session);
wph_render_diff_summary($session);
echo '<div class="wph-layout">';
wph_render_sidebar($session);
echo '<div>';
foreach (WPH_Steps::instance()->all() as $step) {
wph_render_step_card($session, $step);
}
echo '</div></div>';
}
function wph_render_sidebar(WPH_Session $session): void {
echo '<aside class="wph-sidebar"><div class="wph-card"><h3>Steps</h3><ol>';
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
$cls = 'dot-' . $state['status'];
printf(
'<li><span class="dot %s"></span><a href="#step-%s">%s</a></li>',
esc_attr($cls),
esc_attr($step->id()),
esc_html($step->title())
);
}
echo '</ol></div></aside>';
}
function wph_render_blocked_summary(WPH_Session $session): void {
$blocked = [];
foreach (WPH_Steps::instance()->all() as $step) {
$state = $session->step_state($step->id());
if ($state['status'] === WPH_Session::STATUS_BLOCKED) {
$blocked[] = ['step' => $step, 'state' => $state];
}
}
if (!$blocked) return;
echo '<div class="wph-card" style="border-left:4px solid #c0392b">';
echo '<h2 style="color:#721c24">Stop &amp; escalate</h2>';
echo '<p>The following steps are blocked. Resolve or escalate before continuing:</p>';
echo '<ul style="margin-left:1.25rem">';
foreach ($blocked as $b) {
$esc = $b['step']->escalation();
echo '<li><a href="#step-' . esc_attr($b['step']->id()) . '"><strong>' . esc_html($b['step']->title()) . '</strong></a>';
if ($esc) echo ' — ' . esc_html($esc);
if (!empty($b['state']['notes'])) echo '<br><em>' . esc_html($b['state']['notes']) . '</em>';
echo '</li>';
}
echo '</ul></div>';
}
function wph_render_diff_summary(WPH_Session $session): void {
$prev = WPH_Session::previous();
if (!$prev) return;
// Aggregate findings by step+id from each session
$current_idx = [];
foreach (WPH_Steps::instance()->all() as $step) {
$r = $session->get_autocheck($step->id());
if (!$r) continue;
foreach ($r['findings'] as $f) {
$current_idx[$step->id() . '|' . $f['id']] = $f;
}
}
$prev_idx = [];
foreach (WPH_Steps::instance()->all() as $step) {
$r = $prev->get_autocheck($step->id());
if (!$r) continue;
foreach ($r['findings'] as $f) {
$prev_idx[$step->id() . '|' . $f['id']] = $f;
}
}
$new = $resolved = $changed = [];
foreach ($current_idx as $k => $f) {
if (!isset($prev_idx[$k])) {
if (in_array($f['level'], ['warn', 'bad'], true)) $new[] = $f;
} elseif ($prev_idx[$k]['level'] !== $f['level'] || $prev_idx[$k]['value'] !== $f['value']) {
$changed[] = ['was' => $prev_idx[$k], 'now' => $f];
}
}
foreach ($prev_idx as $k => $f) {
if (!isset($current_idx[$k]) && in_array($f['level'], ['warn', 'bad'], true)) {
$resolved[] = $f;
}
}
if (!$new && !$resolved && !$changed) return;
echo '<div class="wph-diff"><strong>Δ vs. previous session</strong> (finished ' . esc_html(date('Y-m-d', (int) $prev->finished_at())) . ')';
if ($new) echo ' · <span class="delta-new">' . count($new) . ' new issue(s)</span>';
if ($resolved) echo ' · <span class="delta-resolved">' . count($resolved) . ' resolved</span>';
if ($changed) echo ' · <span class="delta-changed">' . count($changed) . ' changed</span>';
echo '</div>';
}
function wph_render_step_card(WPH_Session $session, WPH_Step $step): void {
$state = $session->step_state($step->id());
$status = $state['status'];
$notes = $state['notes'];
?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="wph-step" id="step-<?php echo esc_attr($step->id()); ?>">
<?php wp_nonce_field('wph_save_step_' . $step->id()); ?>
<input type="hidden" name="action" value="wph_save_step">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
<header>
<h2><?php echo esc_html($step->title()); ?></h2>
<span class="wph-step-status wph-status-<?php echo esc_attr($status); ?>"><?php echo esc_html(str_replace('_', ' ', $status)); ?></span>
</header>
<?php if ($blurb = $step->blurb()): ?>
<p><?php echo esc_html($blurb); ?></p>
<?php endif; ?>
<?php if ($items = $step->sub_items()): ?>
<ul class="wph-sub-items">
<?php foreach ($items as $item): ?>
<li><?php echo esc_html($item); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($watch = $step->watch_outs()): ?>
<div class="wph-watch-outs">
<strong>Watch out for:</strong>
<ul style="margin:.2rem 0 0 1rem;">
<?php foreach ($watch as $w): ?>
<li><?php echo esc_html($w); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if ($status === WPH_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
<div class="wph-escalation"><?php echo esc_html($esc); ?></div>
<?php endif; ?>
<?php wph_render_autocheck($session, $step); ?>
<?php $step->render_extra($session->data()); ?>
<p>
<label>
<strong>Status:</strong>
<select name="status">
<?php foreach (WPH_Session::VALID_STATUSES as $s): ?>
<option value="<?php echo esc_attr($s); ?>" <?php selected($status, $s); ?>><?php echo esc_html(str_replace('_', ' ', $s)); ?></option>
<?php endforeach; ?>
</select>
</label>
</p>
<p>
<label><strong>Notes:</strong></label>
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
</p>
<p>
<button class="button">Save step</button>
<?php if ($state['updated_at']): ?>
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span>
<?php endif; ?>
</p>
</form>
<?php
}
function wph_render_finished_panel(WPH_Session $session): void {
$report = wph_build_markdown_report($session);
$admin_email = get_option('admin_email');
?>
<div class="wph-card">
<h2>Healthcheck finished</h2>
<p>Started <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> · Finished <?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?></p>
<div class="wph-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_download_report'); ?>
<input type="hidden" name="action" value="wph_download_report">
<button class="button button-primary">Download Markdown</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_download_html'); ?>
<input type="hidden" name="action" value="wph_download_html">
<button class="button">Download HTML</button>
</form>
<button class="button" onclick="navigator.clipboard.writeText(document.getElementById('wph-report-md').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Markdown',2000)">Copy Markdown</button>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_email_report'); ?>
<input type="hidden" name="action" value="wph_email_report">
<input type="email" name="to" placeholder="recipient@example.com" value="<?php echo esc_attr($admin_email); ?>" required style="min-width:14em">
<button class="button">Email report</button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Discard this finished session?');" style="display:inline">
<?php wp_nonce_field('wph_discard'); ?>
<input type="hidden" name="action" value="wph_discard">
<button class="button button-link-delete">Discard</button>
</form>
</div>
</div>
<div class="wph-card">
<h2>Report preview</h2>
<pre id="wph-report-md" style="white-space:pre-wrap;background:#f6f7f7;padding:1rem;border-radius:6px;max-height:30em;overflow:auto"><?php echo esc_html($report); ?></pre>
</div>
<?php
}
// --- Handlers ----------------------------------------------------------------
function wph_handle_start(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_start');
WPH_Session::start(get_current_user_id());
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_save_step(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
check_admin_referer('wph_save_step_' . $step_id);
$session = WPH_Session::current();
if (!$session || $session->is_finished()) wp_die('No active session.');
if (!WPH_Steps::instance()->get($step_id)) wp_die('Unknown step.');
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : WPH_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
$session->update_step($step_id, $status, $notes);
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
function wph_handle_finish(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_finish');
$session = WPH_Session::current();
if (!$session) wp_die('No active session.');
$session->finish();
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_handle_discard(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_discard');
WPH_Session::discard();
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck'));
exit;
}
function wph_render_autocheck(WPH_Session $session, WPH_Step $step): void {
if (!$step->has_autocheck()) return;
$result = $session->get_autocheck($step->id());
?>
<div class="wph-autocheck">
<header>
<h3>Automated checks</h3>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('wph_refresh_checks_' . $step->id()); ?>
<input type="hidden" name="action" value="wph_refresh_checks">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
<button class="button button-small"><?php echo $result ? 'Refresh' : 'Run checks'; ?></button>
</form>
</header>
<?php if (!$result): ?>
<p class="description">No automated checks have been run for this step yet.</p>
<?php else: ?>
<table>
<?php foreach ($result['findings'] as $finding):
$icon = ['ok' => '✓', 'warn' => '⚠', 'bad' => '✗', 'info' => '·'][$finding['level']] ?? '·';
?>
<tr>
<td class="lvl lvl-<?php echo esc_attr($finding['level']); ?>"><?php echo esc_html($icon); ?></td>
<td class="label"><?php echo esc_html($finding['label']); ?></td>
<td class="value"><?php echo esc_html($finding['value']); ?></td>
<td class="detail"><?php echo esc_html($finding['detail']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<p class="wph-checked-at">Checked <?php echo esc_html(human_time_diff((int) $result['checked_at'], time())); ?> ago (<?php echo esc_html(date('Y-m-d H:i', (int) $result['checked_at'])); ?>)</p>
<?php endif; ?>
</div>
<?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 {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
$action_name = isset($_POST['step_action']) ? sanitize_key((string) $_POST['step_action']) : '';
check_admin_referer('wph_step_action_' . $step_id . '_' . $action_name);
$session = WPH_Session::current();
if (!$session || $session->is_finished()) wp_die('No active session.');
$step = WPH_Steps::instance()->get($step_id);
if (!$step) wp_die('Unknown step.');
// Pass POST through unslashed so handlers see the raw user input.
$input = wp_unslash($_POST);
@set_time_limit(60);
$finding = $step->handle_action($action_name, is_array($input) ? $input : []);
if (is_array($finding)) {
// Append (or replace by id) onto this step's stored findings.
$existing = $session->get_autocheck($step_id);
$findings = $existing['findings'] ?? [];
$replaced = false;
foreach ($findings as $i => $f) {
if (($f['id'] ?? '') === ($finding['id'] ?? '')) {
$findings[$i] = $finding;
$replaced = true;
break;
}
}
if (!$replaced) $findings[] = $finding;
$session->set_autocheck($step_id, $findings);
}
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
function wph_handle_refresh_checks(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
check_admin_referer('wph_refresh_checks_' . $step_id);
$session = WPH_Session::current();
if (!$session || $session->is_finished()) wp_die('No active session.');
$step = WPH_Steps::instance()->get($step_id);
if (!$step) wp_die('Unknown step.');
@set_time_limit(60);
$findings = $step->autocheck($session->data());
$session->set_autocheck($step_id, $findings);
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck#step-' . rawurlencode($step_id)));
exit;
}
function wph_handle_download_report(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_download_report');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
wph_stream_report($session, 'md');
}
function wph_handle_download_html(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_download_html');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
wph_stream_report($session, 'html');
}
function wph_stream_report(WPH_Session $session, string $format): void {
$host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'site';
$host = preg_replace('/[^a-z0-9.-]/i', '', (string) $host);
$stamp = date('Ymd', $session->started_at() ?: time());
nocache_headers();
if ($format === 'html') {
header('Content-Type: text/html; charset=UTF-8');
header('Content-Disposition: attachment; filename="wph-report-' . $host . '-' . $stamp . '.html"');
echo wph_build_html_report($session);
} else {
header('Content-Type: text/markdown; charset=UTF-8');
header('Content-Disposition: attachment; filename="wph-report-' . $host . '-' . $stamp . '.md"');
echo wph_build_markdown_report($session);
}
exit;
}
function wph_handle_email_report(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('wph_email_report');
$session = WPH_Session::current();
if (!$session) wp_die('No session.');
$to = isset($_POST['to']) ? sanitize_email((string) wp_unslash($_POST['to'])) : '';
if (!is_email($to)) wp_die('Bad email address.');
$host = parse_url(get_site_url(), PHP_URL_HOST) ?: 'site';
$subject = 'Site Healthcheck — ' . $host . ' — ' . date('Y-m-d', $session->started_at() ?: time());
$html = wph_build_html_report($session);
$ok = wp_mail($to, $subject, $html, ['Content-Type: text/html; charset=UTF-8']);
wp_safe_redirect(admin_url('tools.php?page=site-healthcheck&wph_mail=' . ($ok ? '1' : '0')));
exit;
}