wavelog/application/libraries/api_v2/Api_v2_resource.php

576 lines
19 KiB
PHP

<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
require_once __DIR__ . '/Api_v2_exception.php';
/**
* API v2 - Abstract resource handler
*
* Base class every REST resource extends. The dispatcher (Api_v2 controller)
* instantiates the matching "<Resource>_resource" class, hands it the
* authentication context plus the parsed request body, and then calls one of
* the verb methods below.
*
* Subclasses override only the verbs they support. Any verb left unimplemented
* falls through to the default here, which throws a 405 method_not_allowed -
* except show(), which throws a 404 because a resource that has no per-item GET
* has no addressable items in the first place.
*
* Verb -> method mapping (performed by the dispatcher):
* GET /res -> index()
* GET /res/{id} -> show($id)
* POST /res -> create()
* PUT /res/{id} -> replace($id) full replacement (idempotent)
* PATCH /res/{id} -> update($id) partial update of given fields
* DELETE /res/{id} -> delete($id)
*
* PUT and PATCH are intentionally distinct: PUT replaces the whole resource
* (omitted fields are reset), PATCH only touches the fields present in the body.
*/
abstract class Api_v2_resource {
/** @var CI_Controller The CodeIgniter super-object. */
protected $CI;
/** @var array Authentication context filled by the dispatcher. */
protected $auth;
/** @var array|null Decoded JSON request body (null if none/invalid). */
protected $request_body;
/**
* Scope name of this resource (e.g. "qso", "station"). Combined with the
* HTTP verb to the required token scope: GET needs "<scope>:read", DELETE
* "<scope>:delete", every other verb "<scope>:write". null marks a public
* resource (no scope check).
*
* @var string|null
*/
protected $scope = null;
/**
* Verb handler method => scope suffix it requires. Single source of truth
* for the read/write/delete split, used by the registry derivation in
* scope_definitions(). A resource "supports" a suffix when it overrides at
* least one of the methods mapped to it (un-overridden verbs 405 in the base
* class and therefore contribute no scope).
*
* @var array<string,string>
*/
protected const VERB_SCOPE = [
'index' => 'read',
'show' => 'read',
'create' => 'write',
'replace' => 'write',
'update' => 'write',
'delete' => 'delete',
];
/**
* HTTP verb => the handler methods it maps to. A resource supports a verb
* when it overrides at least one of them; the counterpart to VERB_SCOPE,
* which answers what scope a verb needs rather than whether it exists.
*
* @var array<string,string[]>
*/
protected const VERB_HANDLERS = [
'GET' => ['index', 'show'],
'POST' => ['create'],
'PUT' => ['replace'],
'PATCH' => ['update'],
'DELETE' => ['delete'],
];
/**
* QSL confirmation type names accepted by every resource that filters on
* them (Statistic ?profile=confirmations, Confirmation list). The canonical
* source of truth is Logbook_model::CONFIRMATION_COLUMNS; the list is copied
* here as a plain literal so it is available without the model loaded
* (scope_registry() reflects over every resource at startup).
*/
protected const CONFIRMATION_TYPES = ['lotw', 'eqsl', 'qsl', 'qrz', 'clublog'];
/**
* @param array $auth { id, user_id, created_by, scopes (string[]) }
* @param array|null $body Parsed JSON body, or null for verbs without one.
*/
public function __construct($auth, $body = null) {
$this->CI =& get_instance();
$this->auth = $auth;
$this->request_body = $body;
}
/**
* The token scope required to call this resource with the given verb, or
* null when the resource is public. Evaluated by the dispatcher before
* the verb handler runs.
*
* @param string $method HTTP verb (uppercase).
* @return string|null
*/
public function required_scope($method) {
if ($this->scope === null) {
return null;
}
switch ($method) {
case 'GET':
return $this->scope . ':read';
case 'DELETE':
return $this->scope . ':delete';
default:
return $this->scope . ':write';
}
}
/**
* The HTTP verbs this resource actually implements, for the Allow header of
* a 405. Derived from the verb methods the concrete class overrides, the same
* way scope_definitions() derives the scopes — so the header can never
* advertise a verb that only ends up in the base class' 405 stub.
*
* @return string[] e.g. ['GET', 'POST', 'PATCH', 'DELETE']
*/
public function supported_methods() {
$verbs = [];
foreach (self::VERB_HANDLERS as $verb => $handlers) {
foreach ($handlers as $handler) {
if ((new ReflectionMethod(static::class, $handler))->getDeclaringClass()->getName() !== self::class) {
$verbs[] = $verb;
break;
}
}
}
return $verbs;
}
/**
* The scope ids this resource contributes to the central registry, as
* "scope-id => human-readable label". Derived purely from $scope and the
* verb methods this concrete class actually overrides, so the registry can
* never drift from what the dispatcher enforces. Public resources
* ($scope === null) contribute nothing.
*
* @return array<string,string>
*/
public static function scope_definitions() {
$scope = (new ReflectionClass(static::class))->getDefaultProperties()['scope'] ?? null;
if ($scope === null) {
return [];
}
$labels = static::scope_labels();
$out = [];
foreach (self::VERB_SCOPE as $method => $suffix) {
// A verb counts only when the concrete class overrides it; methods
// still declared on the abstract base are 405 stubs (unsupported).
if ((new ReflectionMethod(static::class, $method))->getDeclaringClass()->getName() === self::class) {
continue;
}
$id = $scope . ':' . $suffix;
// Several methods can map to the same suffix (index + show => read);
// keying by id de-dupes automatically.
$out[$id] = $labels[$suffix] ?? self::default_label($suffix, $scope);
}
return $out;
}
/**
* Whether this resource exists on this instance. If the same function
* exists in the ressource itself, it will be called instead of this one.
* This allows to disable a resources based on other parameters. This
* function is the default for any ressource which does not redefine it.
*
* @return bool
*/
public static function is_available() {
return true;
}
/**
* Whether the *current session* may hand out this resource's scopes when
* creating a token. The counterpart to is_available(): that one answers
* whether the resource exists on this instance at all, this one whether the
* user in front of the token dialog has any use for it.
*
* Only the token UI asks (Api_v2_model::grantable_scope_registry()), and
* only for resources that are available already. The dispatcher must never
* call it - a token outlives the session that created it, and a request
* carries no session at all.
*
* @return bool
*/
public static function is_grantable() {
return true;
}
/**
* Translated labels for this resource's scopes, keyed by suffix
* ("read" | "write" | "delete"). Override in each resource; the strings
* must be static __() literals so po_gen.sh can extract them. The base
* returns none, falling back to default_label().
*
* @return array<string,string>
*/
protected static function scope_labels() {
return [];
}
/**
* Generic, untranslated fallback label for a suffix a resource did not
* describe. Resources should always provide their own labels.
*/
protected static function default_label($suffix, $scope) {
return ucfirst($suffix) . ' ' . $scope;
}
// --- Default verb handlers: 405 until a subclass overrides them --------
public function index() { $this->method_not_allowed(); }
// A resource without show() has no addressable items, so /res/{id} is not a
// URL that exists - 404 rather than "wrong verb on an existing URL".
public function show($id) { throw new Api_v2_exception('not_found', 'Unknown resource item', 404); }
public function create() { $this->method_not_allowed(); }
public function replace($id) { $this->method_not_allowed(); } // PUT: full replace
public function update($id) { $this->method_not_allowed(); } // PATCH: partial update
public function delete($id) { $this->method_not_allowed(); }
// --- Helpers for subclasses -------------------------------------------
/**
* Return the full decoded request body, or an empty array if none.
*/
protected function body() {
return is_array($this->request_body) ? $this->request_body : [];
}
/**
* Read a single query-string parameter with XSS cleaning.
*
* @param string $key Query parameter name.
* @param mixed $default Returned when the parameter is absent.
*/
protected function param($key, $default = null) {
$value = $this->CI->input->get($key, true);
return ($value === null || $value === false || $value === '') ? $default : $value;
}
/**
* The user_id that owns the data this key may touch.
*/
protected function user_id() {
return $this->auth['user_id'];
}
// --- Clubstation permission levels -------------------------------------
//
// The API counterpart to clubaccess_check() (helpers/club_helper.php), which
// reads cd_p_level and operator_callsign from the session and returns true
// unconditionally without one - useless for a sessionless request. Here the
// level travels in the auth context instead (see Api_v2::resolve_club_context).
//
// Levels, as defined in controllers/Club.php:
// 3 = Club Member, 6 = Club Member ADIF, 9 = Club Officer.
// A personal token is not a club token at all and is never restricted.
/**
* Clubstation permission level behind this token, or null for a personal one.
*/
protected function club_permission() {
return $this->auth['club_permission'] ?? null;
}
/**
* Callsign of the member acting behind a club token (null for personal
* tokens). This is what their QSOs are logged under, and what the
* per-operator restrictions are matched against.
*/
protected function operator_callsign() {
return $this->auth['operator_callsign'] ?? null;
}
/**
* Whether this request comes from a club member below officer level, i.e.
* one that may only ever touch its own QSOs. The single condition every
* per-operator restriction hangs off.
*/
protected function is_restricted_club_member() {
$permission = $this->club_permission();
return $permission !== null && $permission < 9;
}
/**
* Guard an operation that requires a minimum clubstation permission level.
* A no-op for personal tokens.
*
* @param int $level Minimum p_level (typically 9 for officer-only actions).
* @throws Api_v2_exception 403 when the member's level is too low.
*/
protected function require_club_level($level) {
$permission = $this->club_permission();
if ($permission === null || $permission >= $level) {
return;
}
throw new Api_v2_exception(
'insufficient_club_permission',
'This operation requires clubstation permission level ' . $level,
403,
['required_level' => $level, 'granted_level' => $permission]
);
}
/**
* Guard a write operation: throw 403 unless the token carries this
* resource's write scope. The dispatcher already enforces scopes per verb;
* this is an extra safety net for handlers that mutate data internally.
*/
protected function require_write() {
$this->require_scope_suffix('write');
}
/**
* Guard a delete operation, analogous to require_write().
*/
protected function require_delete() {
$this->require_scope_suffix('delete');
}
/**
* Throw 403 unless the token carries "<scope>:<suffix>".
*/
protected function require_scope_suffix($suffix) {
$required = $this->scope . ':' . $suffix;
if (!in_array($required, $this->auth['scopes'] ?? [], true)) {
throw new Api_v2_exception('insufficient_scope', 'Token is missing the required scope: ' . $required, 403);
}
}
/**
* Resolve pagination from the query string with sane defaults and caps.
*
* @param int $default_per_page Default page size.
* @param int $max_per_page Hard upper bound on page size.
* @return array { page, per_page, offset }
*/
protected function pagination($default_per_page = 50, $max_per_page = 500) {
$page = (int) $this->param('page', 1);
if ($page < 1) {
$page = 1;
}
$per_page = (int) $this->param('per_page', $default_per_page);
if ($per_page < 1) {
$per_page = $default_per_page;
}
if ($per_page > $max_per_page) {
$per_page = $max_per_page;
}
return [
'page' => $page,
'per_page' => $per_page,
'offset' => ($page - 1) * $per_page,
];
}
/**
* Station location ids owned by the token user. Every resource scopes its
* queries to these, so a token can only ever reach its owner's data.
*
* @return int[]
*/
protected function owner_station_ids() {
$this->CI->load->model('stations');
$ids = [];
$query = $this->CI->stations->all_of_user($this->user_id());
if ($query !== null) {
foreach ($query->result() as $row) {
$ids[] = (int) $row->station_id;
}
}
return $ids;
}
/**
* The station ids a request runs against: all of the owner's stations, or
* the ownership-checked subset named in the query parameter.
*
* Ids the token does not own are rejected with a 403 rather than silently
* dropped, so a caller never receives data for a station set other than the
* one it asked for.
*
* @param string $param Query parameter holding the comma-separated ids.
* @return int[]
* @throws Api_v2_exception 400 on a non-numeric id, 403 on a foreign one.
*/
protected function resolve_station_ids($param = 'station_id') {
$owned = $this->owner_station_ids();
$requested = $this->param($param);
if ($requested === null || $requested === '') {
return $owned;
}
$ids = [];
foreach (explode(',', $requested) as $sid) {
$sid = trim($sid);
if (!is_numeric($sid)) {
throw new Api_v2_exception('validation_error', $param . ' values must be numeric', 400);
}
$sid = (int) $sid;
if (!in_array($sid, $owned, true)) {
throw new Api_v2_exception('forbidden', $param . ' not accessible for this token', 403);
}
$ids[] = $sid;
}
return array_values(array_unique($ids));
}
/**
* Normalise a ?band= filter: '' when absent, 'SAT' uppercased (matches
* COL_PROP_MODE), any other band lowercased (matches COL_BAND). Unknown
* values pass through and simply match no rows rather than erroring.
*/
protected function normalize_band($raw) {
if ($raw === null || $raw === '') {
return '';
}
$band = strtolower(trim($raw));
return ($band === 'sat') ? 'SAT' : $band;
}
/**
* Normalise a ?mode= filter: '' when absent, else uppercased (COL_MODE /
* COL_SUBMODE are stored uppercase). Matched against either column, so a
* submode like FT8 is found regardless of how it was stored.
*/
protected function normalize_mode($raw) {
if ($raw === null || $raw === '') {
return '';
}
return strtoupper(trim($raw));
}
/**
* Normalise a ?callsign= filter: '' when absent, else uppercased (COL_CALL is
* stored uppercase). Validated with the same loose rule as the QSO import
* (letters, digits, / and -), so junk like spaces or commas is rejected with
* a 400 instead of silently matching nothing.
*/
protected function normalize_callsign($raw) {
if ($raw === null || $raw === '') {
return '';
}
$this->CI->load->model('logbook_model');
if (!$this->CI->logbook_model->is_valid_callsign($raw)) {
throw new Api_v2_exception('validation_error', 'Invalid callsign', 400);
}
return strtoupper(trim($raw));
}
/**
* Parse a comma-separated query parameter into a validated lowercase list,
* or null when the parameter is absent. Values outside $allowed produce a
* 400 carrying the allowed list, so a client can correct itself.
*
* @param string $key Query parameter name (e.g. "qsl_filter", "type").
* @param string[] $allowed Whitelist of accepted values.
* @return string[]|null
*/
protected function parse_type_list($key, $allowed) {
$raw = $this->param($key);
if ($raw === null || $raw === '') {
return null;
}
$values = array_map('strtolower', array_map('trim', explode(',', $raw)));
$invalid = array_diff($values, $allowed);
if (!empty($invalid)) {
throw new Api_v2_exception(
'validation_error',
'Invalid ' . $key . ' values: ' . implode(', ', $invalid),
400,
['allowed' => $allowed]
);
}
return array_values(array_unique($values));
}
/**
* Parse a date query parameter in YYYY-MM-DD form, or '' when absent. The
* round-trip comparison rejects calendar-invalid dates like 2026-02-31,
* which DateTime would otherwise silently roll over into March.
*
* @return string '' or a valid Y-m-d date.
*/
protected function parse_date($key) {
$raw = $this->param($key);
if ($raw === null || $raw === '') {
return '';
}
$raw = trim($raw);
$date = DateTime::createFromFormat('Y-m-d', $raw);
if ($date === false || $date->format('Y-m-d') !== $raw) {
throw new Api_v2_exception(
'validation_error',
$key . ' must be a valid date',
400,
['format' => 'YYYY-MM-DD']
);
}
return $raw;
}
/**
* Guard against nested JSON: every body value must be a scalar or null.
* Arrays/objects would otherwise blow up deep in the data layer (500);
* fail early with a clear 400 instead.
*
* @param array $body Decoded request body.
* @throws Api_v2_exception 400 when a non-scalar value is found.
*/
protected function require_scalar_fields($body) {
foreach ($body as $key => $value) {
if ($value !== null && !is_scalar($value)) {
throw new Api_v2_exception(
'validation_error',
'Field "' . $key . '" must be a scalar value',
400,
['field' => $key]
);
}
}
}
/**
* Build the pagination meta block for list responses.
*
* `count` is the number of items on this page; `total` is the number across
* all pages. `total_pages` and `has_more` are derived so a client knows
* definitively when it has reached the last page (no need to probe for an
* empty response).
*
* @param array $page { page, per_page, offset }
* @param int $count Items returned on this page.
* @param int $total Items across all pages for the current filter.
*/
protected function list_meta($page, $count, $total = 0) {
$total = (int) $total;
$per_page = $page['per_page'];
$total_pages = $per_page > 0 ? (int) ceil($total / $per_page) : 0;
return [
'page' => $page['page'],
'per_page' => $per_page,
'count' => $count,
'total' => $total,
'total_pages' => $total_pages,
'has_more' => $page['page'] < $total_pages,
];
}
/**
* Throw a 405 with the standard error code.
*/
protected function method_not_allowed() {
throw new Api_v2_exception('method_not_allowed', 'HTTP method not allowed for this resource', 405);
}
}