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:
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user