'); * * — 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; } }