Dashboard: reopen a finished healthcheck (hc-lh6)

Adds a Reopen button on the session-detail view for finished sessions.
POSTs to /dashboard/healthchecks/{id}/reopen with a per-session CSRF
token; on success clears finished_at and redirects back to the session.

Also tightens Controllers/Healthchecks::update so a PUT with no
finished_at key is a no-op instead of silently setting it to NULL —
otherwise an empty PUT would reopen any completed healthcheck.

After reopening, the WP-side Session::resume($id) flow pulls the now-
active session back into an editable local session on the client site.
This commit is contained in:
2026-07-21 11:52:00 +01:00
parent 6e7820630e
commit d4f643528d
4 changed files with 71 additions and 4 deletions

View File

@@ -19,6 +19,25 @@ use AttHc\Server\Store;
*/
final class Dashboard {
public static function reopen(array $params): void {
self::requireAuth();
self::requireCsrf();
$hcId = (string) ($params['id'] ?? '');
$hc = Store::getHealthcheck($hcId);
if ($hc === null) {
self::html('Not found', '<p class="error">Session not found.</p>');
return;
}
Store::updateHealthcheck($hcId, null);
$siteKey = (string) $hc['site_key'];
$back = '/dashboard?site=' . rawurlencode($siteKey) . '&hc=' . rawurlencode($hcId);
header('Location: ' . $back);
exit;
}
public static function index(array $params = []): void {
self::requireAuth();
@@ -41,6 +60,29 @@ final class Dashboard {
}
}
// ── CSRF ─────────────────────────────────────────────────────────────────
/** Returns the per-session CSRF token, generating one on first call. */
private static function csrfToken(): string {
if (empty($_SESSION['att_hc_csrf']) || !is_string($_SESSION['att_hc_csrf'])) {
$_SESSION['att_hc_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['att_hc_csrf'];
}
/** Aborts with 403 unless the POSTed csrf token matches the session's. */
private static function requireCsrf(): void {
$submitted = isset($_POST['csrf']) && is_string($_POST['csrf']) ? $_POST['csrf'] : '';
$expected = isset($_SESSION['att_hc_csrf']) && is_string($_SESSION['att_hc_csrf'])
? $_SESSION['att_hc_csrf']
: '';
if ($expected === '' || !hash_equals($expected, $submitted)) {
http_response_code(403);
self::html('Forbidden', '<p class="error">CSRF token missing or invalid. Reload the page and try again.</p>');
exit;
}
}
// ── Auth ─────────────────────────────────────────────────────────────────
private static function requireAuth(): void {
@@ -175,13 +217,24 @@ final class Dashboard {
? '<span class="badge status-in-progress">in progress</span>'
: '<span class="badge status-done">complete</span>';
$reopenForm = '';
if ($finished !== null) {
$reopenForm = '<form method="post" action="/dashboard/healthchecks/' . rawurlencode($hcId) . '/reopen"'
. ' onsubmit="return confirm(\'Reopen this healthcheck? It will be editable again from within WordPress.\');"'
. ' class="reopen-form">'
. '<input type="hidden" name="csrf" value="' . htmlspecialchars(self::csrfToken(), ENT_QUOTES) . '">'
. '<button type="submit" class="btn-reopen">Reopen session</button>'
. '</form>';
}
$meta = '<div class="session-meta">
<span>' . $badge . '</span>
<span>Started: <strong>' . $started . '</strong></span>'
. ($finished !== null ? '<span>Finished: <strong>' . $finished . '</strong></span>' : '')
. ($duration !== null ? '<span>Duration: <strong>' . $duration . '</strong></span>' : '')
. '<span class="muted small">ID: ' . htmlspecialchars($hcId) . '</span>
</div>';
. '<span class="muted small">ID: ' . htmlspecialchars($hcId) . '</span>'
. $reopenForm
. '</div>';
if (empty($steps)) {
return $back . '<h2>' . htmlspecialchars($siteKey) . ' — ' . $started . '</h2>'
@@ -300,6 +353,12 @@ pre.step-notes { margin: 0; font-family: inherit; white-space: pre-wrap;
.login-box button { background: #2271b1; color: #fff; border: none; border-radius: 4px;
padding: .5rem 1rem; font-size: 1rem; cursor: pointer; }
.login-box button:hover { background: #135e96; }
/* reopen */
.reopen-form { margin: 0 0 0 auto; }
.btn-reopen { background: #fff; color: #2271b1; border: 1px solid #2271b1;
border-radius: 4px; padding: .25rem .7rem; font-size: .85em;
cursor: pointer; font-weight: 500; }
.btn-reopen:hover { background: #2271b1; color: #fff; }
</style>
</head>
<body>

View File

@@ -41,8 +41,14 @@ final class Healthchecks {
return;
}
$body = Http::readJsonBody();
$finishedAt = Validate::optionalInt($body, 'finished_at');
Store::updateHealthcheck($id, $finishedAt);
// Missing key = no-op. Explicit null = reopen. Int = finish/update.
// The array_key_exists check prevents an empty PUT from silently
// reopening a completed healthcheck.
if (array_key_exists('finished_at', $body)) {
$finishedAt = Validate::optionalInt($body, 'finished_at');
Store::updateHealthcheck($id, $finishedAt);
}
Http::json(200, ['ok' => true]);
}

View File

@@ -29,5 +29,6 @@ $router->add('GET', '/healthchecks/steps/{step_id}', [Steps::class
$router->add('GET', '/step-counts', [Steps::class, 'counts']);
$router->add('GET', '/sites', [Sites::class, 'recent']);
$router->add('GET', '/dashboard', [Dashboard::class, 'index'], requiresAuth: false);
$router->add('POST', '/dashboard/healthchecks/{id}/reopen', [Dashboard::class, 'reopen'], requiresAuth: false);
return $router;