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>
187 lines
7.4 KiB
PHP
187 lines
7.4 KiB
PHP
<?php
|
|
if (!defined('ABSPATH')) exit;
|
|
|
|
/**
|
|
* HTTP client for the central healthcheck history server.
|
|
*
|
|
* 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>');
|
|
*
|
|
* — 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 self::url() !== '' && self::key() !== '';
|
|
}
|
|
|
|
/**
|
|
* If config is missing or broken, returns a human message; otherwise null.
|
|
* Used by the admin page to block the UI with a clear error.
|
|
*/
|
|
public static function config_error(): ?string {
|
|
$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 (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;
|
|
}
|
|
|
|
private static function is_loopback(string $url): bool {
|
|
$host = parse_url($url, PHP_URL_HOST) ?: '';
|
|
return in_array($host, ['localhost', '127.0.0.1', '::1'], true);
|
|
}
|
|
|
|
/** GET / — verifies reachability + auth-free heartbeat. */
|
|
public static function ping(): array {
|
|
return self::request('GET', '/', null, false);
|
|
}
|
|
|
|
public static function create_healthcheck(array $payload): array {
|
|
return self::request('POST', '/healthchecks', $payload);
|
|
}
|
|
|
|
public static function update_healthcheck(string $id, array $payload): array {
|
|
return self::request('PUT', '/healthchecks/' . rawurlencode($id), $payload);
|
|
}
|
|
|
|
public static function get_healthcheck(string $id): array {
|
|
return self::request('GET', '/healthchecks/' . rawurlencode($id));
|
|
}
|
|
|
|
public static function list_healthchecks(string $site_key, bool $include_steps = false, int $limit = 50): array {
|
|
$query = ['site_key' => $site_key, 'limit' => $limit];
|
|
if ($include_steps) $query['include'] = 'steps';
|
|
return self::request('GET', '/healthchecks?' . http_build_query($query));
|
|
}
|
|
|
|
public static function upsert_step(string $healthcheck_id, string $step_id, array $payload): array {
|
|
return self::request(
|
|
'PUT',
|
|
'/healthchecks/' . rawurlencode($healthcheck_id) . '/steps/' . rawurlencode($step_id),
|
|
$payload
|
|
);
|
|
}
|
|
|
|
public static function step_history(string $step_id, string $site_key, int $limit = 5, ?string $exclude_id = null): array {
|
|
$query = ['site_key' => $site_key, 'limit' => $limit];
|
|
if ($exclude_id !== null && $exclude_id !== '') $query['exclude_id'] = $exclude_id;
|
|
return self::request(
|
|
'GET',
|
|
'/healthchecks/steps/' . rawurlencode($step_id) . '?' . http_build_query($query)
|
|
);
|
|
}
|
|
|
|
public static function recent_sites(int $limit = 20): array {
|
|
return self::request('GET', '/sites?' . http_build_query(['limit' => $limit]));
|
|
}
|
|
|
|
/** Returns ['counts' => ['step_id' => int, ...]] — used to render the "(N)" badge. */
|
|
public static function step_counts(string $site_key, ?string $exclude_id = null): array {
|
|
$query = ['site_key' => $site_key];
|
|
if ($exclude_id !== null && $exclude_id !== '') $query['exclude_id'] = $exclude_id;
|
|
return self::request('GET', '/step-counts?' . http_build_query($query));
|
|
}
|
|
|
|
/**
|
|
* @throws ATT_HC_Api_Exception
|
|
*/
|
|
private static function request(string $method, string $path, ?array $body = null, bool $requires_auth = true): array {
|
|
$err = self::config_error();
|
|
if ($err !== null) {
|
|
throw new ATT_HC_Api_Exception($err, 'no_config');
|
|
}
|
|
|
|
$url = rtrim(self::url(), '/') . $path;
|
|
|
|
$args = [
|
|
'method' => $method,
|
|
'timeout' => self::TIMEOUT_SECONDS,
|
|
'redirection' => 0,
|
|
'headers' => [
|
|
'Accept' => 'application/json',
|
|
],
|
|
];
|
|
if ($requires_auth) {
|
|
$args['headers']['Authorization'] = 'Bearer ' . self::key();
|
|
}
|
|
if ($body !== null) {
|
|
$args['headers']['Content-Type'] = 'application/json';
|
|
$args['body'] = wp_json_encode($body);
|
|
}
|
|
|
|
$response = wp_remote_request($url, $args);
|
|
|
|
if (is_wp_error($response)) {
|
|
throw new ATT_HC_Api_Exception(
|
|
'Central history server unreachable: ' . $response->get_error_message(),
|
|
'unreachable'
|
|
);
|
|
}
|
|
|
|
$status = (int) wp_remote_retrieve_response_code($response);
|
|
$raw = (string) wp_remote_retrieve_body($response);
|
|
$data = $raw !== '' ? json_decode($raw, true) : [];
|
|
if (!is_array($data)) $data = [];
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
$code = isset($data['code']) && is_string($data['code']) ? $data['code'] : 'http_' . $status;
|
|
$message = isset($data['error']) && is_string($data['error'])
|
|
? $data['error']
|
|
: 'central server returned HTTP ' . $status;
|
|
if ($status === 401) {
|
|
$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);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
}
|