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.
Reverts the earlier Apache-2.0 change in favour of a proprietary licence
matching the other internal ATT plugins (att-consent). Grants use only to
authorised sites; all other rights reserved. MIT carve-out preserved for
vendored PUC.
Three issues from the 2026-07-16 security review:
- hc-gp3 (P2): API key echoed in settings input + plaintext in wp_options
- hc-adg (P2): recovery installer pulls unpinned 'latest' without signature
- hc-ufl (P3): no rate limiting on API or /dashboard, unrotated error_log
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal-only tool, so no distribution obligations under either license.
Apache-2.0 chosen for its explicit patent grant. Can be relicensed to GPL
later if wp.org distribution becomes relevant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No functional changes; version bump to verify PUC-based updates from Gitea
land on installed sites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vendors YahnisElsts/plugin-update-checker v5.7 under vendor/ and wires it
to poll updates.json on the repo main branch. WP surfaces new versions
via Dashboard → Updates on its normal twice-daily cadence.
PUC is vendored (not a submodule) so Gitea's archive/main.zip — the
download target — includes it. Folder rename on install is handled by
PUC's built-in upgrader_source_selection filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slot two new steps between performance (Step 7) and security (Step 8):
- 72-analytics.php: sniffs the homepage for GA4 (G-), GTM, and legacy UA
measurement IDs plus known loader URLs (gtag.js, gtm.js, analytics.js,
ga.js) and detects common analytics/tag plugins. Warns if only UA is
still in use.
- 74-search-console.php: looks for google-site-verification meta tags on
the homepage, probes for a reachable sitemap (wp-sitemap.xml, then
sitemap_index.xml, then sitemap.xml), parses robots.txt for a
Googlebot/* Disallow: /, flags the WP "Discourage search engines"
setting when on, and notes whether Site Kit is active.
Titles use the "Step —" (unnumbered) convention already used by the
email and handover steps so the existing numbered steps don't shift.
steps.md updated to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Session list rows are now clickable links. Clicking a session shows all
its steps with status badge, notes, and last-updated time. Step IDs are
formatted into human-readable labels. Status badges cover all five states
(done, skipped, blocked, n/a, not started).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Browsable at /dashboard — authenticates via ?key=<api_key> once per browser
session (stored in a PHP session cookie). Lists all sites ordered by most-
recently-active, with session counts and in-progress badge. Click any site
to see its full healthcheck history with dates and durations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Some managed hosts (WPE and various resellers, plus some clients' own ops
teams) don't let us edit wp-config.php. Add a settings-screen alternative
so the plugin can be configured without touching filesystem constants.
Storage + resolution:
- Two new options: att_hc_api_url and att_hc_api_key, autoload=false on
the key so it isn't loaded on every request.
- ATT_HC_Api::url() and ATT_HC_Api::key() are the single source of truth
now — they return the wp-config constant when defined+non-empty, else
the option, else ''. Everything else (request(), config_error(),
is_configured(), the admin config-error notice) uses these accessors.
- url_from_constant() / key_from_constant() drive per-field locking on
the settings page and are also checked by the save handler so a
constant-locked field can't be overridden by a crafted POST.
UI:
- New "Central history server" card at the top of Tools → Site
Healthcheck → Settings with URL (type=url) and API key (type=password)
inputs. When a constant is defined the field is disabled with a
"Set via <constant> constant" hint.
- Separate form action/nonce (att_hc_save_api_settings) so it doesn't
tangle with the existing Gitea recovery save.
- The blocking config-error notice on the main page now offers an
"Open settings" button alongside the wp-config.php snippet.
Verified with an 18-assertion test suite covering no-config, options-
only, http-blocked-with-clear-message, loopback-http-allowed, and
constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a final "Notes for next time" step so the tech finishing today can flag
pending issues, watch-fors, and outstanding client decisions for whoever
picks up the next healthcheck on the same site.
On ATT_HC_Session::start() for a given site_key, the server's step history
for the handover step is queried (limit 1, excluding the just-created
session). If a prior session left handover notes, they're written into
the new session's "Before You Start" notes prefixed with the prior
session's date ("From previous session (YYYY-MM-DD):") so the carry-over
is obvious. The tech can edit/clear them as normal step notes from there.
- includes/steps/125-handover.php — new step (id=handover) using the
standard notes field. No server schema or API change; it's just another
step row in step_updates, surfaced like any other.
- ATT_HC_Session::seed_before_notes_from_prior_handover() — best-effort,
silent degrade on API failure. The session is already registered on
the server before this runs, so a failed seed never blocks start.
- No seed on resume() — resuming an existing session would clobber
whatever the tech had already typed.
Verified end-to-end against the live MySQL server: handover-test-XXXX
flow shows carry-over with date prefix; no-handover-XXXX flow confirms
no false-positive seed for a fresh site_key. Test rows purged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The central history server work introduced four PHP 8.0+ syntax sites in
the plugin codebase. The main plugin file's "Requires PHP: 7.4" header
was already there; the code had silently drifted past that bound.
- includes/class-att-hc-session.php:244 — str_starts_with($host, 'www.')
→ substr($host, 0, 4) === 'www.'.
- includes/class-att-hc-api.php:52 — self::request(…, requires_auth: false)
→ positional false. Same default semantics, same callee signature.
- includes/admin-page.php:165 and class-att-hc-session.php:126 —
list_healthchecks(…, include_steps: …, limit: …) → positional. Same
values, no semantic change.
Verified by linting all 16 plugin files against PHP 7.4.33 — no syntax
errors, no residual PHP 8+ patterns (str_starts_with/contains/ends_with,
nullsafe, enum, readonly, mixed/never, constructor promotion, match).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Some VPS configs can't get pdo_sqlite at all — Ubuntu 20.04 with Ondrej
Sury's PHP 8.3 builds doesn't ship php8.3-sqlite3, and OS upgrade isn't
always an option. Make MySQL the documented default while keeping SQLite
working where it's available.
- Schema (0001_initial.sql): TEXT → VARCHAR(N), INTEGER → BIGINT, keys
declared with explicit PRIMARY KEY (...) syntax. Drops the previously
unused request_log table (it had AUTO_INCREMENT, which spells
differently on each engine and nothing wrote to it anyway). Both
engines accept the new column types and indexes are IF NOT EXISTS for
retry-safety.
- Migrations.php: guard commit()/rollBack() with inTransaction(). MySQL
implicitly commits any open transaction the moment it sees a DDL
statement, so by the time we explicitly commit() the transaction is
already gone and PDO throws "There is no active transaction". Same
schema in PHP CREATE TABLE migrations also moved to VARCHAR/BIGINT.
- Store::upsertStep: driver-detect via PDO::ATTR_DRIVER_NAME and emit
ON DUPLICATE KEY UPDATE for MySQL, ON CONFLICT (...) DO UPDATE for
SQLite/PostgreSQL. VALUES(col) (vs new.col aliasing) for MySQL 5.7
compatibility.
- Db.php: when DSN is mysql:, SET NAMES utf8mb4 + sql_mode strict on
every session so we get sane behaviour regardless of server defaults.
SQLite branch (PRAGMA foreign_keys/journal_mode/synchronous) unchanged.
- config.php.example: MySQL DSN is now the default + an inline SQLite
alternative block.
- DEPLOY.md: new "Database — MySQL or SQLite" section explaining when to
pick which and showing the CREATE DATABASE / CREATE USER / GRANT
statements. Install snippet split so SQLite-only steps (mkdir data,
chmod 770) are clearly optional.
Verified end-to-end on a live MySQL 8.0.34 box: POST creates session
(201), PUT step inserts (200) and updates via the upsert branch (200),
GET returns the round-tripped state, /sites lists distinct site_keys.
SQLite path still re-applies the migration idempotently locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server/ directory is a separate deployable, not part of the
WordPress plugin. Without this, `git archive` would bundle the
PHP/SQLite server code inside the plugin zip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues hit during a real deploy:
1. The nginx example pinned fastcgi_pass to /run/php/php8.2-fpm.sock.
When the target box runs a different PHP-FPM (8.3 here), the socket
doesn't exist and nginx 502s. Switch to /run/php/php-fpm.sock (the
alternatives symlink installed by Ondrej Sury's packaging) so the
default config works against whatever current PHP is installed,
with a comment explaining the fallback for boxes without the symlink.
2. The PHP version requirement was buried in a one-liner. Promote it to
a multi-line note that names the specific 8.x features used, says
loudly that 7.x will not parse the code, and points at Sury's repo
for boxes stuck on an old default.
Also added a callout warning against cp foo.conf foo.conf.bak inside
sites-enabled/, since nginx loads every file in that directory and the
backup becomes a second vhost claiming the same server_name (silently
shadowed via the "conflicting server name … ignored" warning).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a PHP/SQLite history server in server/ and refactors the plugin to
write every session change through it. Healthcheck history now survives
plugin uninstall and groups across dev + live URLs for the same engagement
via an editable site_key (defaults to the normalised host).
Server (server/):
- Front controller + hand-rolled autoloader, no framework, no composer
- SQLite default DSN; swap to MySQL by changing config.php
- Schema: healthchecks (PK id, UNIQUE (site_key, started_at)) + step_updates
(PK (healthcheck_id, step_id)) + request_log; auto-migration runner
- 8 endpoints: POST/GET/PUT healthchecks, PUT/GET step rows, GET step history
with exclude_id, GET /sites (recent), GET /step-counts (badge data)
- Bearer auth via hash_equals; HTTPS expected (plugin enforces client-side)
- DEPLOY.md with Apache/nginx vhosts, Let's Encrypt, SQLite backup cron,
and the /home/www/ perm gotcha
- dev-router.php works around PHP -S 405-ing dotted uniqid paths
Plugin:
- ATT_HC_Api HTTP client reads ATT_HC_API_URL/ATT_HC_API_KEY constants
from wp-config.php; refuses non-HTTPS with a loopback dev exception
- ATT_HC_Session is now write-through: every start/update_step/finish/
set_autocheck POSTs or PUTs to the server first, then updates the local
WP option cache. No drift possible — failures throw ATT_HC_Api_Exception
- previous() now reads from /healthchecks?include=steps and reconstructs;
the old att_hc_previous_session local option is gone
- ATT_HC_Session::resume(id) hydrates a server session into the local cache
- Start screen: editable site_key (defaults to normalise_site_url()),
datalist of recent engagements, table of in-progress sessions for the
chosen key with Resume buttons. Double-click guard on start + resume
handlers short-circuits if a session is already active
- Per-step <details> disclosure shows "Previous notes (N)" badge from
/step-counts; lazy-loads detail rows on first expand via admin-ajax,
caches via data-loaded, resets on error so user can retry
- All admin handlers catch ATT_HC_Api_Exception and surface via
att_hc_api_error transient → admin notice
- Hard config-error gate at the top of the admin page blocks the UI when
ATT_HC_API_URL/ATT_HC_API_KEY are missing or malformed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wholesale rename to avoid clashes with generic 'site healthcheck'
plugin names on a target site:
- Plugin Name: 'Site Healthcheck' → 'ATT Site Healthcheck'
- Main file: site-healthcheck.php → att-site-healthcheck.php
- Plugin folder: site-healthcheck → att-site-healthcheck
- Admin menu slug: site-healthcheck → att-site-healthcheck
- Settings slug: site-healthcheck-settings → att-site-healthcheck-settings
- PHP class prefix: WPH_ → ATT_HC_
- Function prefix: wph_ → att_hc_
- Option / transient: wph_* → att_hc_*
- Action/filter: wph_* → att_hc_*
- CSS class prefix: wph- → att-hc-
- Constants: WPH_GITEA_* → ATT_HC_GITEA_*
- Class file names: class-wph-*.php → class-att-hc-*.php
- Dev folder: ~/dev/wp-healthcheck → ~/dev/att-site-healthcheck
Existing in-progress sessions on installs that had the old wph_session
option will not migrate — they were intended for dev use only and the
user has confirmed this is OK for the rename window.
Smoke-tested on testsite: classes load, 14 steps discovered, save/load
round-trip works, admin page renders with new att-hc- CSS classes.
Recovery plugin detection unchanged — that lives in wp-site-recovery
and continues to be detected by Name + Author header.
Step 3 (Core) now does three independent lookups:
- Installed version (the truth, from $wp_version)
- Latest per WordPress (via get_core_updates — what WP_Downgrade can
manipulate)
- Latest per wp.org (direct call to api.wordpress.org/core/version-check/1.7/
cached 1h, can't be cloaked from the server side)
If 'Latest per WordPress' < 'Latest per wp.org', the finding is bumped
to bad with a CLOAKED detail message. The wp_downgrade_core_version
option is also surfaced verbatim so it's obvious where the pin lives.
Step 4 (Plugins) similarly:
- wp_org_info() now captures the version field from the WP.org plugins
info API. Cache key bumped to wph_pi2_ so existing entries get
re-fetched.
- For each plugin: installed version vs wp.org reported version.
- If installed < wp.org AND the WP update_plugins transient doesn't
flag it, the row is bad with 'CLOAKED — WP says up-to-date'.
- Cloaked count surfaces in the summary.
Both steps also explicitly detect a roster of known cloaker plugins
(WP Downgrade, WP Rollback, Easy Updates Manager, Disable Updates
Manager, Stops Core/Theme/Plugin Updates, Companion Auto Update)
and warn when any are active, so the technician sees the framing
before diving into individual rows.
Smoke-tested with WP Downgrade installed and wp_downgrade_core_version
set: both findings fire correctly, target version is surfaced.
The step card's save form wrapped the entire card, including the
autocheck 'Run checks' button and per-step extras like the email
'Send' form — those are also <form> elements. Nested forms are
invalid HTML; browsers silently drop the outer form's submit when
they see an inner one.
So clicking 'Save step' was effectively a no-op: the POST to
wph_save_step never reached the server, and the status reverted to
'not started' on reload. The bug had been there since the autocheck
panel was introduced in v0.3 of the healthcheck.
Restructured the step card as a <div> wrapper containing three
siblings: (1) static content (title, blurb, sub-items, watch-outs,
escalation), (2) the autocheck panel + step render_extra (each with
their own forms), (3) the save form. No nesting anywhere.
Verified end-to-end on testsite: step status and notes now persist
across page loads. The email step (which has 3 sibling forms — refresh
checks, send test, save) also renders without nesting.
The old #1a8917 green and 0.6rem dots were subtle enough on some
displays to read as dark grey. Bumping to a vivid #16a34a, 0.75rem
diameter, with a faint inset shadow for crispness on light
backgrounds. Other states adjusted to match the same brighter palette
(amber #f59e0b skipped, red #dc2626 blocked, neutral #9ca3af n/a).
Also dims the link text to a dark green for done rows via :has()
so the whole line reads as 'this is complete'.
Replaces the 'inactive themes count' finding with a clearer breakdown:
Keep set (each gets a 'Keep — <why>' row):
- The active theme (always)
- The parent of the active theme (if a child theme is in use and
the parent exists on disk)
- The newest Twenty* default installed (as a fallback we can swap
to if the active theme breaks during work)
Removal candidates (one row per inactive theme not in the keep set):
- 'Candidate' rows for the technician to walk down and remove
- Summary line warns at >3 candidates
- 'none' ok row if every installed theme is a keeper
Smoke-tested on testsite with three configurations:
- Default theme active, no child: keep active + Twenty Twenty-Five
fallback; one removal candidate.
- Cluttered (Astra + Hello Elementor + extra Twenty* installed):
same keepers, 4 candidates surfaced.
- Astra active (custom, no child): keep Astra + Twenty Twenty-Five
fallback; 4 candidates including any unused Twenty* and Hello
Elementor.
Same as the matching change in wp-site-recovery — excludes .beads/,
.claude/, AGENTS.md, CLAUDE.md, .gitignore, .gitattributes itself,
and steps.md (technician guide, not used at runtime) from any release
archive.
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.
When debug capture is requested, the trace is now also appended to the
step's notes textarea with a clear --- SMTP debug trace ... --- header
and timestamp. This means:
- The technician can edit/trim the trace before finishing the session.
- It flows naturally into the Markdown + HTML report via the existing
notes-in-report rendering — no special-casing needed.
- Pre-existing notes are preserved (appended to, not overwritten).
- Status is preserved.
- The collapsible details block on the step card still shows the raw
capture for quick reference.
Checkbox on the send-test form turns on PHPMailer SMTPDebug=2 for one
send only. A Debugoutput callback captures the wire conversation;
hook is removed immediately after wp_mail returns.
AUTH credentials redacted: lines containing 'AUTH' or 16+ char base64
strings get replaced. Trace capped at 200 lines and stashed on the
session. Rendered as a collapsible <details> block on the step card.
Transport type (mail/smtp/etc., read from PHPMailer->Mailer) surfaced
in the finding detail so the technician knows whether a trace will
contain useful SMTP traffic — only the smtp transport emits any.
Demonstrates the drop-in pattern: adding a step with a form is a single
new file under includes/steps/, plus one small extension to the base
abstraction.
WPH_Step gains:
- render_extra($session_state) — emit extra HTML inside the step card
- handle_action($name, $input) — handle a step-specific POST and
return a finding to record
New generic admin-post handler wph_step_action routes form submissions
to the matching step's handle_action() with nonce verification.
The email step (includes/steps/115-email.php):
- Detects 7 known SMTP plugins, surfaces default From address
- Renders a To: input prefilled with the current admin's email
- Sends via wp_mail() with wp_mail_failed capture so failures show the
underlying error
- Records the outcome as a 'last_send' finding so it appears on the
step card, in the summary, and in the downloadable report
Infrastructure:
- WPH_Step::autocheck($session_state) returns an array of findings
shaped {id, level: ok/warn/bad/info, label, value, detail}.
- WPH_Session stores results keyed by step id (persisted in the option-
backed session).
- WPH_Step::has_autocheck() reflection check so the UI only renders the
panel for steps that implement automation.
- 'Run checks' / 'Refresh' button per step, admin-post handler runs
autocheck() and stashes the result on the session.
- Findings rendered as a coloured table on the step card; included
verbatim in the Markdown report with status icons.
Step automations implemented:
- Step 1 (Backup): detection of 11 known backup plugins by slug;
active/inactive state; UpdraftPlus last-backup timestamp.
- Step 2 (Environment): PHP version + EOL, WP version vs latest, disk
usage, wp-config flags, file perms on wp-config/wp-content/uploads,
error-log sizes.
- Step 4 (Plugins): WP.org API enrichment with 24h transient cache —
last_updated, active_installs, abandonment flag, removed-from-repo
flag, update-available count. Summary line at the top.
- Step 8 (Security): SSL cert expiry via stream_socket_client +
openssl_x509_parse, administrator audit, xmlrpc reachability, login
URL hardening detection.
- Step 9 (Database): spam comments, post revisions, autoload size (WP
6.6+ value handling), top 3 largest tables.
- Step 11 (Small fixes): deactivated-but-installed plugin list,
homepage alt-text scan.
Smoke-tested on testsite — all six steps return findings with
correctly-classified levels. Report regenerated with automated findings
section.
Plugin skeleton, drop-in step registry, option-backed session, single-page
checklist UI, downloadable Markdown report. Steps live as one file each
under includes/steps/ — adding/removing one is a single file change.
Step IDs are stable strings so renaming files preserves session data.
Architecture (hc-5ix.3): WPH_Steps singleton globs includes/steps/*.php,
natsort-orders by filename, requires each file (which returns a WPH_Step
instance), then applies a 'wph_steps' filter so installs can drop steps.
Session (hc-5ix.4): option-backed (per decision — plugin is installed
per-engagement, so DB-resident history would be lost on uninstall).
Single in-progress session per site; finished sessions render a report
that the user downloads/copies.
Recovery bootstrap (hc-5ix.1): detects whether wp-site-recovery is
installed + active, surfaces state on the start panel and in every
active session. Manual install for now; private update channel deferred
to hc-5ix.27.
Smoke-tested on testsite: registry discovery (13 steps in correct order),
start → update_step → progress count → finish → 8KB Markdown report →
discard cycle.