Plugin: settings-screen fallback for API URL + API key (hc-4m5)
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>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 '<h1>Site Healthcheck</h1>';
|
||||
|
||||
if ($cfg_err = ATT_HC_Api::config_error()) {
|
||||
$settings_url = admin_url('tools.php?page=att-site-healthcheck-settings');
|
||||
echo '<div class="notice notice-error"><p><strong>Central history server not usable:</strong> ' . esc_html($cfg_err) . '</p>';
|
||||
echo '<p>Add the following to <code>wp-config.php</code> and reload:</p>';
|
||||
echo '<p><a class="button button-primary" href="' . esc_url($settings_url) . '">Open settings</a> to set the server URL and API key, or add the following to <code>wp-config.php</code>:</p>';
|
||||
echo '<pre>define(\'ATT_HC_API_URL\', \'https://your-history-server.example.com\');' . "\n" . 'define(\'ATT_HC_API_KEY\', \'<shared secret>\');</pre></div>';
|
||||
echo '</div>';
|
||||
return;
|
||||
@@ -721,6 +723,44 @@ function att_hc_render_settings_page(): void {
|
||||
|
||||
<?php if ($saved): ?><div class="notice notice-success is-dismissible"><p>Saved.</p></div><?php endif; ?>
|
||||
|
||||
<div class="att-hc-card">
|
||||
<h2>Central history server</h2>
|
||||
<p>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.</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). 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.</p>
|
||||
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
|
||||
<?php wp_nonce_field('att_hc_save_api_settings'); ?>
|
||||
<input type="hidden" name="action" value="att_hc_save_api_settings">
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th><label for="att-hc-api-url">Server URL</label></th>
|
||||
<td>
|
||||
<input id="att-hc-api-url" type="url" name="api_url" value="<?php echo esc_attr(ATT_HC_Api::url()); ?>" class="regular-text" placeholder="https://healthcheck-history.example.com" <?php disabled(ATT_HC_Api::url_from_constant()); ?>>
|
||||
<?php if (ATT_HC_Api::url_from_constant()): ?>
|
||||
<p class="description">Set via <code>ATT_HC_API_URL</code> constant.</p>
|
||||
<?php else: ?>
|
||||
<p class="description">Must start with <code>https://</code>. No trailing slash necessary.</p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="att-hc-api-key">API key</label></th>
|
||||
<td>
|
||||
<input id="att-hc-api-key" type="password" name="api_key" value="<?php echo esc_attr(ATT_HC_Api::key()); ?>" class="regular-text" autocomplete="new-password" <?php disabled(ATT_HC_Api::key_from_constant()); ?>>
|
||||
<?php if (ATT_HC_Api::key_from_constant()): ?>
|
||||
<p class="description">Set via <code>ATT_HC_API_KEY</code> constant.</p>
|
||||
<?php else: ?>
|
||||
<p class="description">Shared secret. Copy it from the server's <code>config.php</code>. Stored in the WP options table.</p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>
|
||||
<button class="button button-primary">Save settings</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="att-hc-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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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', '<long random string>');
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user