[/] (resource names are singular, lowercase) * - Verbs: GET (read), POST (create), PUT/PATCH (update), DELETE (delete) * - Auth: Authorization: Bearer (X-API-Key accepted as fallback) * Only the new "wl2_" tokens (table `api_token`, Api_v2_model) * are accepted; legacy v1 keys are rejected. Each token carries * granular scopes ("qso:read", "qso:write", ...) which are * enforced per resource and verb before dispatching. * - Response: slim envelope { "data": ... } / { "error": { code, message } }; * the HTTP status carries the semantics. * * Adding a new resource requires no change here: drop a * `application/libraries/api_v2/_resource.php` class extending * Api_v2_resource and it is dispatched by convention. * * @see application/config/routes.php ($route['api/v2/(:any)']) * @see application/libraries/api_v2/Api_v2_resource.php */ class Api_v2 extends CI_Controller { public function __construct() { parent::__construct(); $this->load->library('api_v2_response'); $this->load->model('api_v2_model'); } /** * Single entry point for all /api/v2/... requests. * * The catch-all route forwards the path after /api/v2/ as individual URI * segments, so CodeIgniter passes them as separate arguments here. We * collect them all via a variadic parameter (e.g. route('qso', '42')). * * @param string ...$segments The path segments after /api/v2/. */ public function route(...$segments) { $method = $this->input->method(true); // Answer CORS preflight before doing any work. if ($method === 'OPTIONS') { $this->handle_preflight(); return; } // Drop any empty segments (e.g. from a trailing slash). $segments = array_values(array_filter($segments, function ($s) { return $s !== '' && $s !== null; })); $resource = $segments[0] ?? ''; $id = $segments[1] ?? null; try { if (count($segments) > 2) { throw new Api_v2_exception('not_found', 'Unknown resource path', 404); } // Public meta endpoints: GET /api/v2 and GET /api/v2/status. // Neither has addressable items, so a trailing id is not a URL either. if ($resource === '' || $resource === 'status') { if ($id !== null) { throw new Api_v2_exception('not_found', 'Unknown resource path', 404); } if ($method !== 'GET') { $this->reject_method(['GET']); } $this->api_v2_response->respond([ 'name' => 'Wavelog API', 'status' => 'ok', ]); return; } // Everything past here requires a valid API token. try { $auth = $this->authenticate(); } catch (Api_v2_exception $e) { // Throttle failed attempts by IP; a rejected token never reaches // the per-token limit below. Only failures count, so valid // clients sharing a NAT address are spared. $this->enforce_rate_limit('api_v2_auth', $this->input->ip_address()); throw $e; } // Per-resource rate limiting keyed by the token id (the plaintext // token is never kept around after authentication). $this->enforce_rate_limit('api_v2_' . $resource, $auth['id']); $handler = $this->load_resource($resource, $auth, $method); // Verb support first: a verb the resource does not implement is a // 405, not a permission problem. Checking it after the scope check // would answer "insufficient_scope" for a scope that cannot exist // (e.g. POST on a read-only resource needs ":write", which is // never in the registry) and hide the real reason from the client. $allowed = $handler->supported_methods(); if (!in_array($method, $allowed, true)) { $this->reject_method($allowed); } // Scope enforcement: the resource declares its scope name, the // verb decides between :read and :write. null = public resource. $required = $handler->required_scope($method); if ($required !== null && !in_array($required, $auth['scopes'], true)) { throw new Api_v2_exception( 'insufficient_scope', 'Token is missing the required scope: ' . $required, 403, ['required_scope' => $required] ); } $this->dispatch($handler, $method, $id); } catch (Api_v2_exception $e) { $this->api_v2_response->error( $e->get_error_code(), $e->getMessage(), $e->get_http_status(), $e->get_details() ); } catch (Throwable $e) { log_message('error', 'API v2 unhandled: ' . $e->getMessage()); $this->api_v2_response->error('internal_error', 'Internal server error', 500); } } /** * Validate the Bearer/X-API-Key credential and build the auth context. * Only v2 tokens ("wl2_" prefix) are accepted; scope checks happen later * in route() once the target resource is known. * * @return array { id, user_id, created_by, scopes } * @throws Api_v2_exception 401 */ protected function authenticate() { $token = $this->extract_key(); if ($token === null) { throw new Api_v2_exception('unauthorized', 'Missing API token', 401); } // Fast-fail anything that is not a v2 token (e.g. legacy v1 keys). if (strpos($token, Api_v2_model::TOKEN_PREFIX) !== 0) { throw new Api_v2_exception( 'invalid_token', 'API v2 requires a ' . Api_v2_model::TOKEN_PREFIX . ' token; legacy v1 API keys are not accepted', 401 ); } $auth = $this->api_v2_model->authenticate_token($token); if ($auth === null) { throw new Api_v2_exception('invalid_token', 'Invalid or revoked API token', 401); } if ($auth['expired']) { throw new Api_v2_exception('token_expired', 'API token has expired', 401); } $auth = $this->resolve_club_context($auth); $this->api_v2_model->update_last_used($auth['id'], $auth['user_id']); return $auth; } /** * Re-check the clubstation membership behind a club token and add the club * context the resources need to enforce the permission levels. * * A club token belongs to the clubstation (user_id) but was issued by a * member acting on its behalf (created_by). Two things follow from that: * * - The membership can be withdrawn at any time and the token carries no * record of it, so without this check a removed member would keep full * access to the club logbook until the token happens to expire. * - What the member may do depends on the permission level behind that * membership (3 = member, 6 = member ADIF, 9 = officer). The web UI reads * it from the session via clubaccess_check(), which is useless here, so * the level travels in the auth context instead. * * Personal tokens (owner == creator) skip both lookups entirely and keep * null for the club fields - they are never restricted by club rules. * * @param array $auth Authentication context from authenticate_token(). * @return array The same context plus club_permission and operator_callsign. * @throws Api_v2_exception 403 once the membership is gone. */ protected function resolve_club_context($auth) { $auth['club_permission'] = null; $auth['operator_callsign'] = null; // Same master switch clubaccess_check() uses: with the clubstation // feature off there are no club rules to apply, for the API no more than // for the web UI. if (!$this->config->item('special_callsign')) { return $auth; } if ($auth['created_by'] === $auth['user_id']) { return $auth; } $this->load->model('club_model'); // Returns 0 for a user that is no longer a member of the clubstation. $permission = (int) $this->club_model->get_permission_noui($auth['user_id'], $auth['created_by']); if ($permission < 1) { // An administrator manages every clubstation in the web UI // so he should be able to manage any club also via the API if ($this->user_model->is_admin($auth['created_by'])) { $permission = 9; } else { // The token is valid, the permission behind it is not: 403, not 401. throw new Api_v2_exception( 'club_access_revoked', 'The clubstation membership behind this token has been revoked', 403 ); } } $auth['club_permission'] = $permission; // The acting member's own callsign: what their QSOs are logged under and // what the per-operator restrictions are matched against. $creator = $this->user_model->get_by_id($auth['created_by']); if ($creator !== null && $creator->num_rows() > 0) { $auth['operator_callsign'] = $creator->row()->user_callsign; } return $auth; } /** * Read the API key from the Authorization: Bearer header, falling back to * the X-API-Key header. * * @return string|null */ protected function extract_key() { $auth_header = $this->input->get_request_header('Authorization', true); if ($auth_header && preg_match('/^Bearer\s+(\S+)$/i', trim($auth_header), $m)) { return $m[1]; } $api_key_header = $this->input->get_request_header('X-API-Key', true); if ($api_key_header) { return trim($api_key_header); } return null; } /** * Instantiate the resource handler for the given resource name. * * @throws Api_v2_exception 404 when no handler exists for the resource. */ protected function load_resource($resource, $auth, $method) { // Resource names are singular lowercase; the class is "_resource". if (!preg_match('/^[a-z][a-z0-9_]*$/', $resource)) { throw new Api_v2_exception('not_found', 'Unknown resource', 404); } $class = ucfirst($resource) . '_resource'; $file = APPPATH . 'libraries/api_v2/' . $class . '.php'; if (!is_file($file)) { throw new Api_v2_exception('not_found', 'Unknown resource: ' . $resource, 404); } require_once $file; // The class must be a concrete subclass of Api_v2_resource. This also // rejects the abstract base class itself (e.g. GET /api/v2/api_v2). if (!class_exists($class) || !is_subclass_of($class, 'Api_v2_resource')) { throw new Api_v2_exception('not_found', 'Unknown resource: ' . $resource, 404); } if (!$class::is_available()) { throw new Api_v2_exception('not_found', 'Unknown resource: ' . $resource, 404); } $body = in_array($method, ['POST', 'PUT', 'PATCH'], true) ? $this->read_json_body() : null; return new $class($auth, $body); } /** * Map the HTTP verb to a handler method and invoke it. * * @throws Api_v2_exception 405 for unsupported verbs. */ protected function dispatch($handler, $method, $id) { // route() has already rejected any verb this resource does not implement; // the base class' 405 stubs stay in place as a safety net. $allowed = $handler->supported_methods(); switch ($method) { case 'GET': ($id === null) ? $handler->index() : $handler->show($id); break; case 'POST': if ($id !== null) { // POST creates, it never addresses an existing item: the // remaining verbs are exactly the id-addressable ones. $this->reject_method(array_values(array_diff($allowed, ['POST']))); } $handler->create(); break; case 'PUT': // Full replacement of the resource (idempotent). if ($id === null) { throw new Api_v2_exception('not_found', 'Missing resource id', 404); } $handler->replace($id); break; case 'PATCH': // Partial update of the supplied fields. if ($id === null) { throw new Api_v2_exception('not_found', 'Missing resource id', 404); } $handler->update($id); break; case 'DELETE': if ($id === null) { throw new Api_v2_exception('not_found', 'Missing resource id', 404); } $handler->delete($id); break; default: $this->reject_method(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); } } /** * Read and decode the JSON request body. * * @throws Api_v2_exception 400 on malformed JSON. */ protected function read_json_body() { $raw = file_get_contents('php://input'); if ($raw === '' || $raw === false) { return []; } $decoded = json_decode($raw, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { throw new Api_v2_exception('invalid_json', 'Request body is not valid JSON', 400); } return is_array($decoded) ? $decoded : []; } /** * Enforce rate limiting for the given endpoint/identifier. * No-op unless `api_rate_limits` is configured. * * @throws Api_v2_exception 429 when the limit is exceeded. */ protected function enforce_rate_limit($endpoint, $identifier) { if (!$this->load->is_loaded('rate_limit')) { $this->load->library('rate_limit'); } $result = $this->rate_limit->check($endpoint, $identifier); if (!$result['allowed']) { $this->output->set_header('Retry-After: ' . $result['retry_after']); throw new Api_v2_exception( 'rate_limited', 'Rate limit exceeded. Try again in ' . $result['retry_after'] . ' seconds.', 429, ['retry_after' => $result['retry_after']] ); } } /** * Throw a 405 with the appropriate Allow header. */ protected function reject_method($allowed) { $this->output->set_header('Allow: ' . implode(', ', $allowed)); throw new Api_v2_exception('method_not_allowed', 'HTTP method not allowed', 405); } /** * Answer a CORS preflight request. */ protected function handle_preflight() { $this->output ->set_header('Access-Control-Allow-Origin: *') ->set_header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS') ->set_header('Access-Control-Allow-Headers: Authorization, Content-Type, X-API-Key') ->set_header('Access-Control-Max-Age: 86400') ->set_status_header(204); } }