mirror of
https://github.com/wavelog/wavelog
synced 2026-08-13 18:41:16 -04:00
576 lines
19 KiB
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);
|
|
}
|
|
}
|