wavelog/application/libraries/api_v2/Station_resource.php
2026-08-05 06:32:38 +00:00

391 lines
14 KiB
PHP

<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
require_once __DIR__ . '/Api_v2_resource.php';
/**
* API v2 - Stations resource
*
* Exposes the token owner's station locations (CRUD). Reuses the existing
* Stations / Stationsetup_model data access; ownership is always enforced
* against the token's user_id, never the session.
*
* Route: /api/v2/station
* Scope: station:read / station:write / station:delete
*
* Only the core location fields are writable via the API; external-service
* credentials (QRZ/HRDLog/ClubLog/OQRS/webADIF/eQSL) are intentionally not
* exposed here and keep their defaults on create.
*/
class Station_resource extends Api_v2_resource {
/** Token scope of this resource (see Api_v2_resource::required_scope()). */
protected $scope = 'station';
/**
* DXCC entities that have ADIF subdivisions (secondary administrative
* areas); the county field is only kept for these, mirroring
* Stations::add()/edit(). Anything else clears the county.
*/
protected const COUNTY_DXCC = [6, 110, 291, 15, 54, 61, 126, 151, 288, 339, 170, 21, 29, 32, 281];
/**
* Fields a station location cannot exist without. Name and callsign identify
* it; DXCC, CQ and ITU are copied into every QSO logged from the location and
* land in integer columns there, so a location lacking them produces QSOs
* that cannot be written. The web UI marks the same fields as required
* (views/station_profile/create.php) and derives CQ/ITU from the DXCC choice.
*/
protected const REQUIRED_FIELDS = ['name', 'callsign', 'dxcc', 'cq', 'itu'];
/** Registry labels for this resource's scopes (see scope_definitions()). */
protected static function scope_labels() {
return [
'read' => __('Read station locations'),
'write' => __('Create and update station locations'),
'delete' => __('Delete station locations'),
];
}
/**
* GET /api/v2/station
* All station locations of the token owner. No pagination: users only
* have a handful of station profiles.
*/
public function index() {
$query = $this->CI->stations->all_of_user($this->user_id());
$stations = [];
foreach ($query->result() as $row) {
$stations[] = $this->format_station($row);
}
$this->CI->api_v2_response->respond($stations);
}
/**
* GET /api/v2/station/{id}
* A single station location owned by the token holder.
*/
public function show($id) {
$this->require_owned_station($id);
$query = $this->CI->stations->profile($id);
if ($query->num_rows() === 0) {
throw new Api_v2_exception('not_found', 'Station not found', 404);
}
$this->CI->api_v2_response->respond($this->format_station($query->row()));
}
/**
* POST /api/v2/station
* Create a station location. Required body fields: see REQUIRED_FIELDS.
*/
public function create() {
$this->require_write();
$this->require_club_level(9);
$this->CI->load->model('stations');
$this->CI->load->model('stationsetup_model');
$body = $this->body();
$this->require_scalar_fields($body);
$this->require_present($body, self::REQUIRED_FIELDS);
$this->validate_grid($body);
// The first station of a user becomes the active/default one, mirroring
// Stations::add(); find_active() is session-bound and unusable here.
$active = $this->user_has_active_station() ? 0 : 1;
$dbdata = $this->build_columns($body, true, null);
$dbdata['user_id'] = $this->user_id();
$dbdata['station_active'] = $active;
$dbdata['webadifapiurl'] = 'https://qo100dx.club/api';
$dbdata['station_uuid'] = $this->CI->db->query("SELECT UUID() as uuid")->row()->uuid;
$optiondata = ['eqsl_default_qslmsg' => '', 'link_active_logbook' => 'false'];
$result = $this->CI->stationsetup_model->save_location($dbdata, $optiondata, $this->user_id());
if ($result === false) {
throw new Api_v2_exception('validation_error', 'Missing required field(s): name, callsign', 400, ['missing' => ['name', 'callsign']]);
}
if ($result === 0) {
throw new Api_v2_exception('conflict', 'An identical station location already exists', 409);
}
$row = $this->CI->stations->profile_by_uuid($dbdata['station_uuid'], $this->user_id());
$headers = $row ? ['Location' => base_url('index.php/api/v2/station/' . (int) $row->station_id)] : [];
$this->CI->api_v2_response->respond($row ? $this->format_station($row) : null, 201, null, $headers);
}
/**
* PATCH /api/v2/station/{id}
* Partial update: only the fields present in the body are changed.
*/
public function update($id) {
$this->apply_update($id);
}
/**
* DELETE /api/v2/station/{id}
*
* Mirrors the web UI: the active station cannot be deleted (409); any other
* station is removed together with all of its QSOs, QSL/eQSL images and
* related data (Stations::delete() -> deletelog()). This is destructive by
* design and matches Stationsetup::DeleteStation_json().
*/
public function delete($id) {
$this->require_delete();
// Deleting a location takes all of its QSOs with it (Stations::delete()
// -> deletelog()), so this is the most destructive call in the API.
$this->require_club_level(9);
$row = $this->require_owned_station($id);
// The internal Stations::delete() active guard uses the session and is
// a no-op for token requests, so enforce it here from the DB row.
if ((int) $row->station_active === 1) {
throw new Api_v2_exception('conflict', 'The active station location cannot be deleted', 409);
}
// Ownership is already verified; force = true bypasses the (session-based)
// internal guard, which we replaced with the explicit check above.
$this->CI->stations->delete((int) $id, true, $this->user_id());
$this->CI->api_v2_response->no_content();
}
// --- Internal helpers --------------------------------------------------
/**
* PATCH implementation.
*
* @param int $id station_id from the path.
*/
protected function apply_update($id) {
$this->require_write();
$this->require_club_level(9);
$this->CI->load->model('stations');
$this->CI->load->model('stationsetup_model');
$current = $this->require_owned_station($id);
$body = $this->body();
$this->require_scalar_fields($body);
$this->require_not_cleared($body, self::REQUIRED_FIELDS);
$this->validate_grid($body);
$data = $this->build_columns($body, false, (int) $current->station_dxcc);
if (empty($data)) {
throw new Api_v2_exception('validation_error', 'No editable fields in request body', 400);
}
$this->CI->stationsetup_model->update_location((int) $id, $data, $this->user_id());
$this->CI->api_v2_response->respond($this->format_station($this->CI->stations->profile_clean($id)));
}
/**
* Map the JSON body to station_profile columns with the same normalisation
* the web UI applies. A new row needs every column, so create() fills the
* omitted ones with their defaults; PATCH touches only what was supplied.
*
* @param array $body Decoded request body (API field names).
* @param bool $fill_defaults true on create, false on PATCH.
* @param int|null $current_dxcc Existing DXCC (for county gating on PATCH).
* @return array column => value
*/
protected function build_columns($body, $fill_defaults, $current_dxcc) {
$data = [];
foreach ($this->writable_fields() as $key => $def) {
list($col, $normalizer, $default) = $def;
if (array_key_exists($key, $body)) {
$data[$col] = $this->normalize($normalizer, $body[$key]);
} elseif ($fill_defaults) {
$data[$col] = $default;
}
}
// County is only meaningful for DXCC entities with subdivisions; clear
// it otherwise. Effective DXCC = the value in this request, else the
// station's existing one (PATCH without a dxcc field).
$effective_dxcc = array_key_exists('dxcc', $body) ? (int) $body['dxcc'] : $current_dxcc;
if (array_key_exists('cnty', $body)) {
$data['station_cnty'] = in_array($effective_dxcc, self::COUNTY_DXCC, true)
? xss_clean((string) $body['cnty'])
: '';
} elseif ($fill_defaults) {
$data['station_cnty'] = null;
}
return $data;
}
/**
* Writable core location fields: API key => [column, normalizer, reset default].
* External-service credentials are intentionally excluded.
*/
protected function writable_fields() {
return [
'name' => ['station_profile_name', 'string', ''],
'callsign' => ['station_callsign', 'callsign', ''],
'gridsquare' => ['station_gridsquare', 'upper', ''],
'city' => ['station_city', 'string', ''],
'dxcc' => ['station_dxcc', 'int_or_null', null],
'cq' => ['station_cq', 'int_or_null', null],
'itu' => ['station_itu', 'int_or_null', null],
'state' => ['state', 'string', null],
'iota' => ['station_iota', 'upper', ''],
'sota' => ['station_sota', 'upper', ''],
'wwff' => ['station_wwff', 'upper', null],
'pota' => ['station_pota', 'pota', null],
'sig' => ['station_sig', 'upper', null],
'sig_info' => ['station_sig_info', 'upper', null],
'power' => ['station_power', 'power', null],
];
}
/**
* Normalise a single field value the same way the web UI does before it
* reaches the database.
*/
protected function normalize($normalizer, $value) {
switch ($normalizer) {
case 'upper':
return xss_clean(strtoupper(trim((string) $value)));
case 'callsign':
return str_replace('Ø', '0', trim(xss_clean(strtoupper((string) $value))));
case 'pota':
return preg_replace('/\s+/', '', xss_clean(strtoupper((string) $value)));
case 'int_or_null':
return is_numeric($value) ? (int) $value : null;
case 'power':
return is_numeric($value) ? xss_clean((string) $value) : null;
case 'string':
default:
return xss_clean((string) $value);
}
}
/**
* Verify a station exists and belongs to the token owner, returning its row.
*
* @throws Api_v2_exception 404 when missing or not owned.
* @return object station_profile row.
*/
protected function require_owned_station($id) {
if (!is_numeric($id) || (int) $id < 1
|| !$this->CI->stations->check_station_against_user($id, $this->user_id())) {
throw new Api_v2_exception('not_found', 'Station not found', 404);
}
$row = $this->CI->stations->profile_clean($id);
if ($row === null) {
throw new Api_v2_exception('not_found', 'Station not found', 404);
}
return $row;
}
/**
* Throw 400 unless every listed field is present and non-empty in the body.
*/
protected function require_present($body, $fields) {
$missing = [];
foreach ($fields as $field) {
if (empty($body[$field])) {
$missing[] = $field;
}
}
if (!empty($missing)) {
throw new Api_v2_exception(
'validation_error',
'Missing required field(s): ' . implode(', ', $missing),
400,
['missing' => $missing]
);
}
}
/**
* Throw 400 if a PATCH tries to blank out a field the location must keep.
* Fields absent from the body are untouched and therefore fine; only an
* explicit empty value is refused.
*/
protected function require_not_cleared($body, $fields) {
$cleared = [];
foreach ($fields as $field) {
if (array_key_exists($field, $body) && empty($body[$field])) {
$cleared[] = $field;
}
}
if (!empty($cleared)) {
throw new Api_v2_exception(
'validation_error',
'Field(s) cannot be cleared: ' . implode(', ', $cleared),
400,
['fields' => $cleared]
);
}
}
/**
* Validate the gridsquare, if supplied, with the same rule as the web UI
* (Qra::validate_grid). An empty value is allowed.
*
* @throws Api_v2_exception 400 on a malformed locator.
*/
protected function validate_grid($body) {
if (!array_key_exists('gridsquare', $body)) {
return;
}
$grid = trim((string) $body['gridsquare']);
if ($grid === '') {
return;
}
$this->CI->load->library('Qra');
if (!$this->CI->qra->validate_grid($grid)) {
throw new Api_v2_exception('validation_error', 'Invalid grid locator', 400, ['field' => 'gridsquare']);
}
}
/**
* Whether the token owner already has an active station location.
*/
protected function user_has_active_station() {
foreach ($this->CI->stations->all_of_user($this->user_id())->result() as $row) {
if ((int) $row->station_active === 1) {
return true;
}
}
return false;
}
/**
* Shape a station_profile DB row into the public API representation. The
* shape is symmetric with the writable fields, so a GET result can be sent
* straight back through PATCH.
*/
protected function format_station($row) {
return [
'id' => (int) $row->station_id,
'uuid' => $row->station_uuid ?? null,
'name' => $row->station_profile_name ?? null,
'callsign' => $row->station_callsign ?? null,
'gridsquare' => $row->station_gridsquare ?? null,
'city' => $row->station_city ?? null,
'dxcc' => isset($row->station_dxcc) ? (int) $row->station_dxcc : null,
'country' => $row->station_country ?? null,
'cq' => isset($row->station_cq) ? (int) $row->station_cq : null,
'itu' => isset($row->station_itu) ? (int) $row->station_itu : null,
'state' => $row->state ?? null,
'cnty' => $row->station_cnty ?? null,
'iota' => $row->station_iota ?? null,
'sota' => $row->station_sota ?? null,
'wwff' => $row->station_wwff ?? null,
'pota' => $row->station_pota ?? null,
'sig' => $row->station_sig ?? null,
'sig_info' => $row->station_sig_info ?? null,
'power' => isset($row->station_power) && is_numeric($row->station_power) ? (int) $row->station_power : null,
'active' => isset($row->station_active) && $row->station_active == 1,
];
}
}