'); * * 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. * * Every method throws ATT_HC_Api_Exception on failure. Callers must catch and surface. */ final class ATT_HC_Api { private const TIMEOUT_SECONDS = 15; /** 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 !== ''; } /** * 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 { 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.'; } 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.'; } 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, requires_auth: 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(ATT_HC_API_URL, '/') . $path; $args = [ 'method' => $method, 'timeout' => self::TIMEOUT_SECONDS, 'redirection' => 0, 'headers' => [ 'Accept' => 'application/json', ], ]; if ($requires_auth) { $args['headers']['Authorization'] = 'Bearer ' . ATT_HC_API_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 ATT_HC_API_KEY in wp-config.php matches the server config.'; } throw new ATT_HC_Api_Exception($message, $code, $status); } return $data; } }