From 3d67b94896f0c975ce2ca647f8f9d98b9e3a19d3 Mon Sep 17 00:00:00 2001 From: Steve Hanlon Date: Tue, 7 Jul 2026 14:54:15 +0100 Subject: [PATCH] Plugin: settings-screen fallback for API URL + API key (hc-4m5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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" 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) --- .beads/issues.jsonl | 1 + includes/admin-page.php | 63 ++++++++++++++++++++++++++++++++- includes/class-att-hc-api.php | 65 ++++++++++++++++++++++++++--------- 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7cbc310..225522b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"hc-4m5","title":"Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)","description":"Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.\n\nDesign (mirror the pattern already used for the Gitea recovery config on the same settings page):\n\n- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).\n- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.\n- config_error() message updates to say 'add to wp-config.php OR set via Tools -\u003e Site Healthcheck -\u003e Settings'.\n- Settings page gets a new 'Central history server' card above the existing Gitea card, with:\n - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined\n - Key input (type=password, autocomplete=new-password) — same lock behaviour\n- Save handler extends the existing att_hc_save_settings to persist the two options.\n- No migration needed — sites using constants keep working untouched.\n\nSecurity note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.\n\nUser asked for this because some sites can't edit wp-config.php.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-07T13:51:13Z","created_by":"Steve Hanlon","updated_at":"2026-07-07T13:54:15Z","started_at":"2026-07-07T13:51:24Z","closed_at":"2026-07-07T13:54:15Z","close_reason":"Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-eff","title":"Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work","description":"The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.\n\nSites:\n- includes/class-att-hc-session.php:244 — str_starts_with()\n- includes/class-att-hc-api.php:52 — named argument requires_auth: false\n- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:\n- includes/admin-page.php:165 — named arguments include_steps:, limit:\n\nPlugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).\n\nAffects every plugin user on PHP \u003c 8.0.","status":"closed","priority":1,"issue_type":"bug","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-30T08:27:51Z","created_by":"Steve Hanlon","updated_at":"2026-06-30T08:29:22Z","started_at":"2026-06-30T08:27:57Z","closed_at":"2026-06-30T08:29:22Z","close_reason":"Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-cc8","title":"Plugin: per-step 'view previous notes' panel","description":"Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.\n\nOn expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=\u003ccurrent\u003e\u0026limit=5. Each entry shows:\n- started_at (formatted)\n- status badge\n- notes (or 'no notes')\n- reporting_url if different from the current session\n\nLoaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:41:06Z","started_at":"2026-06-29T11:36:10Z","closed_at":"2026-06-29T11:41:06Z","close_reason":"Per-step history disclosure shipped end-to-end.\n\nServer:\n- New GET /step-counts?site_key=...\u0026exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip\n- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel\n- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id\n\nPlugin:\n- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available\n- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer\n- Step card now renders a \u003cdetails class=att-hc-history data-step-id=…\u003e with summary 'Previous notes for this step (N)' when N\u003e0\n- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from \u003curl\u003e' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.\n- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)\n\nVerified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.","dependencies":[{"issue_id":"hc-cc8","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-cc8","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-m1a","title":"Plugin: start screen — site_key field + recent dropdown + resume","description":"When no active local session exists, the start screen shows:\n\n1. Text input 'Site / engagement key' — defaults to normalised get_site_url() (lowercase, strip scheme + leading www., trim trailing slash). Editable. Help text: 'Used to group runs that span dev + live for the same engagement. Pick from recent engagements below if continuing one.'\n2. Dropdown 'Recent engagements' populated from GET /sites?limit=20. Selecting one fills the input.\n3. After site_key is set, GET /healthchecks?site_key=...\u0026include=steps. If any UNFINISHED sessions exist, list them with started_at and progress, each with a [Resume] button. Plus a separate [Start fresh] button.\n\nResume: load the chosen session into the local option (no new POST). Start fresh: new POST /healthchecks.","status":"closed","priority":1,"issue_type":"task","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:05Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:36:09Z","started_at":"2026-06-29T11:33:33Z","closed_at":"2026-06-29T11:36:09Z","close_reason":"Start screen rewritten:\n- Editable 'Site / engagement key' input, defaults to ATT_HC_Session::normalise_site_url(get_site_url()).\n- datalist-backed autocomplete from GET /sites?limit=20 (recent engagements). Picking one fills the input via native browser UX.\n- 'Look up engagement' submits as GET with site_key in the URL, so re-rendering picks it up and re-queries incompletes.\n- Table of in-progress sessions for the selected site_key with started_at, last-activity, reporting_url, and a [Resume] button per row. Each Resume button posts to a new att_hc_handle_resume handler which calls ATT_HC_Session::resume($id).\n- Separate Start button below labelled differently depending on whether incompletes exist.\n\nDouble-click guard: att_hc_handle_start (and att_hc_handle_resume) early-return to the main page if ATT_HC_Session::current() is non-null, so a stray repeat click can't 409 against the (site_key, started_at) unique constraint with a confusing duplicate error.\n\nEnd-to-end verified: /sites populates the datalist, incomplete filter shows 2 alpha + 1 beta + 0 unknown correctly, resume() repopulates cache and restores step notes, finish() removes from incomplete list. Lookup-err and recent-sites-err paths both surface as inline warnings without blocking the Start button.","dependencies":[{"issue_id":"hc-m1a","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:23Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-m1a","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:36Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} diff --git a/includes/admin-page.php b/includes/admin-page.php index 5b8e740..55e4486 100644 --- a/includes/admin-page.php +++ b/includes/admin-page.php @@ -15,6 +15,7 @@ add_action('admin_post_att_hc_email_report', 'att_hc_handle_email_report'); add_action('admin_post_att_hc_step_action', 'att_hc_handle_step_action'); add_action('admin_post_att_hc_recovery_install', 'att_hc_handle_recovery_install'); add_action('admin_post_att_hc_save_settings', 'att_hc_handle_save_settings'); +add_action('admin_post_att_hc_save_api_settings', 'att_hc_handle_save_api_settings'); add_action('admin_enqueue_scripts', 'att_hc_enqueue_assets'); function att_hc_register_menu(): void { @@ -114,8 +115,9 @@ function att_hc_render_admin_page(): void { echo '

Site Healthcheck

'; if ($cfg_err = ATT_HC_Api::config_error()) { + $settings_url = admin_url('tools.php?page=att-site-healthcheck-settings'); echo '

Central history server not usable: ' . esc_html($cfg_err) . '

'; - echo '

Add the following to wp-config.php and reload:

'; + echo '

Open settings to set the server URL and API key, or add the following to wp-config.php:

'; echo '
define(\'ATT_HC_API_URL\', \'https://your-history-server.example.com\');' . "\n" . 'define(\'ATT_HC_API_KEY\', \'<shared secret>\');
'; echo ''; return; @@ -721,6 +723,44 @@ function att_hc_render_settings_page(): void {

Saved.

+
+

Central history server

+

The plugin writes every step to a central server so healthcheck history survives per-engagement plugin uninstalls and follows the site across dev/live URLs.

+

Each field can be set via a constant in wp-config.php (then it takes precedence and the field below is locked). Using constants is preferred where the host allows it — the API key stored as an option is visible to WP admins and anyone with DB access.

+ +
+ + + + + + + + + + + +
+ > + +

Set via ATT_HC_API_URL constant.

+ +

Must start with https://. No trailing slash necessary.

+ +
+ > + +

Set via ATT_HC_API_KEY constant.

+ +

Shared secret. Copy it from the server's config.php. Stored in the WP options table.

+ +
+

+ +

+
+
+

Recovery plugin source (Gitea)

One-click install pulls site-recovery 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.

@@ -793,6 +833,27 @@ function att_hc_handle_save_settings(): void { exit; } +function att_hc_handle_save_api_settings(): void { + if (!current_user_can('manage_options')) wp_die('Forbidden'); + check_admin_referer('att_hc_save_api_settings'); + + // Only touch options the user is actually allowed to write. If a constant + // is defined for a field, we ignore the submitted value (the input is also + // disabled in the UI, but belt-and-braces on the handler too). + if (!ATT_HC_Api::url_from_constant()) { + $url = trim((string) wp_unslash($_POST['api_url'] ?? '')); + $url = rtrim($url, '/'); + update_option(ATT_HC_Api::OPT_URL, $url, false); + } + if (!ATT_HC_Api::key_from_constant()) { + $key = trim((string) wp_unslash($_POST['api_key'] ?? '')); + update_option(ATT_HC_Api::OPT_KEY, $key, false); + } + + wp_safe_redirect(admin_url('tools.php?page=att-site-healthcheck-settings&att_hc_saved=1')); + exit; +} + function att_hc_handle_recovery_install(): void { if (!current_user_can('install_plugins') || !current_user_can('activate_plugins')) wp_die('Forbidden'); check_admin_referer('att_hc_recovery_install'); diff --git a/includes/class-att-hc-api.php b/includes/class-att-hc-api.php index 1a23024..cdefb81 100644 --- a/includes/class-att-hc-api.php +++ b/includes/class-att-hc-api.php @@ -4,25 +4,57 @@ if (!defined('ABSPATH')) exit; /** * HTTP client for the central healthcheck history server. * - * Config comes from constants defined in wp-config.php: + * Config comes from EITHER a wp-config.php constant OR a WP option, with the + * constant winning when both are set: + * * define('ATT_HC_API_URL', 'https://healthcheck-history.example.com'); * define('ATT_HC_API_KEY', ''); * - * Constants (not options) on purpose: the plugin gets uninstalled per engagement, - * but the constants survive in wp-config.php so the next install on the same site - * still talks to the same server. + * — OR set via Tools → Site Healthcheck → Settings, which persists to the + * WP options `att_hc_api_url` and `att_hc_api_key`. + * + * The constant path survives plugin uninstall + reactivation (values live in + * wp-config.php on the filesystem). The option path is easier on managed hosts + * where wp-config.php isn't editable, but the key is then visible to anyone + * with WP admin or DB access — prefer the constant when possible. * * Every method throws ATT_HC_Api_Exception on failure. Callers must catch and surface. */ final class ATT_HC_Api { + public const OPT_URL = 'att_hc_api_url'; + public const OPT_KEY = 'att_hc_api_key'; + private const TIMEOUT_SECONDS = 15; + /** Resolved API URL — constant wins, else option, else ''. */ + public static function url(): string { + if (defined('ATT_HC_API_URL') && is_string(ATT_HC_API_URL) && ATT_HC_API_URL !== '') { + return ATT_HC_API_URL; + } + return (string) get_option(self::OPT_URL, ''); + } + + /** Resolved API key — constant wins, else option, else ''. */ + public static function key(): string { + if (defined('ATT_HC_API_KEY') && is_string(ATT_HC_API_KEY) && ATT_HC_API_KEY !== '') { + return ATT_HC_API_KEY; + } + return (string) get_option(self::OPT_KEY, ''); + } + + /** True if the field is locked by a wp-config constant (used to disable inputs on the settings page). */ + public static function url_from_constant(): bool { + return defined('ATT_HC_API_URL') && is_string(ATT_HC_API_URL) && ATT_HC_API_URL !== ''; + } + + public static function key_from_constant(): bool { + return defined('ATT_HC_API_KEY') && is_string(ATT_HC_API_KEY) && ATT_HC_API_KEY !== ''; + } + /** Returns true if the plugin is configured to talk to a server. */ public static function is_configured(): bool { - return defined('ATT_HC_API_URL') && defined('ATT_HC_API_KEY') - && is_string(ATT_HC_API_URL) && is_string(ATT_HC_API_KEY) - && ATT_HC_API_URL !== '' && ATT_HC_API_KEY !== ''; + return self::url() !== '' && self::key() !== ''; } /** @@ -30,14 +62,13 @@ final class ATT_HC_Api { * Used by the admin page to block the UI with a clear error. */ public static function config_error(): ?string { - if (!defined('ATT_HC_API_URL') || !defined('ATT_HC_API_KEY')) { - return 'Central history server is not configured. Add ATT_HC_API_URL and ATT_HC_API_KEY constants to wp-config.php.'; + $url = self::url(); + $key = self::key(); + if ($url === '' || $key === '') { + return 'Central history server is not configured. Set it via Tools → Site Healthcheck → Settings, or define ATT_HC_API_URL and ATT_HC_API_KEY constants in wp-config.php.'; } - if (!is_string(ATT_HC_API_URL) || !is_string(ATT_HC_API_KEY) || ATT_HC_API_URL === '' || ATT_HC_API_KEY === '') { - return 'ATT_HC_API_URL or ATT_HC_API_KEY in wp-config.php is empty.'; - } - if (stripos(ATT_HC_API_URL, 'https://') !== 0 && !self::is_loopback(ATT_HC_API_URL)) { - return 'ATT_HC_API_URL must start with https:// — refusing to send credentials over plain HTTP.'; + if (stripos($url, 'https://') !== 0 && !self::is_loopback($url)) { + return 'The central history server URL must start with https:// — refusing to send credentials over plain HTTP.'; } return null; } @@ -107,7 +138,7 @@ final class ATT_HC_Api { throw new ATT_HC_Api_Exception($err, 'no_config'); } - $url = rtrim(ATT_HC_API_URL, '/') . $path; + $url = rtrim(self::url(), '/') . $path; $args = [ 'method' => $method, @@ -118,7 +149,7 @@ final class ATT_HC_Api { ], ]; if ($requires_auth) { - $args['headers']['Authorization'] = 'Bearer ' . ATT_HC_API_KEY; + $args['headers']['Authorization'] = 'Bearer ' . self::key(); } if ($body !== null) { $args['headers']['Content-Type'] = 'application/json'; @@ -145,7 +176,7 @@ final class ATT_HC_Api { ? $data['error'] : 'central server returned HTTP ' . $status; if ($status === 401) { - $message = 'Central server rejected our credentials. Check ATT_HC_API_KEY in wp-config.php matches the server config.'; + $message = 'Central server rejected our credentials. Check the API key (Tools → Site Healthcheck → Settings, or ATT_HC_API_KEY in wp-config.php) matches the server config.'; } throw new ATT_HC_Api_Exception($message, $code, $status); }