in the web UI: * * - A club token whose creator is an officer (permission level 9). The * clubstation is implicit - it is the token owner - so ?club_id= is optional * and may only name that same club. * - A personal token of a Wavelog administrator. Administrators manage every * clubstation (Club_model::club_authorize()), so there is nothing implicit * about which one they mean: ?club_id= is mandatory. * * Anything else - a personal token of a regular user, a club token below * officer level - is refused with 403. * * Route: /api/v2/club[/{user_id}][?club_id=] * Scope: club:read / club:write / club:delete */ class Club_resource extends Api_v2_resource { /** Token scope of this resource (see Api_v2_resource::required_scope()). */ protected $scope = 'club'; /** @var int|null Memoized result of resolve_club(). */ protected $club_id = null; /** * The whole resource only exists while the clubstation feature is on. * This overrides the default is_available() of Api_v2_resource, which always returns * true. */ public static function is_available() { $CI =& get_instance(); return (bool) $CI->config->item('special_callsign'); } /** * Only sessions that could actually use these scopes are offered them: an * officer inside a clubstation, or an administrator outside one. A regular * user has no club to address and would end up with a token that can only * ever answer 403. */ public static function is_grantable() { $CI =& get_instance(); // Inside a clubstation the permission level decides. The two cases have // to stay separate because clubaccess_check() returns true outside one. if ($CI->session->userdata('clubstation') == 1) { return clubaccess_check(9); } // Outside: administrators only, who name the club with ?club_id=. return (bool) $CI->user_model->authorize(99); } /** Registry labels for this resource's scopes (see scope_definitions()). */ protected static function scope_labels() { return [ 'read' => __('Read club members'), 'write' => __('Manage club member permissions'), 'delete' => __('Remove club members'), ]; } /** * GET /api/v2/club * * The clubstation's members (callsign, user_name, p_level) - or, for an * administrator who named no club, the list of clubstations to choose from. * That is where the club_id every other call needs comes from, so refusing * it with a 400 would leave them nowhere to look it up. */ public function index() { $club_id = $this->resolve_club(false); if ($club_id === null) { $clubs = []; foreach ($this->CI->club_model->get_all_clubstations() as $club) { $clubs[] = $this->format_clubstation($club); } $this->CI->api_v2_response->respond($clubs, 200, ['count' => count($clubs)]); return; } $members = []; foreach ($this->CI->club_model->get_club_members($club_id) as $member) { $members[] = $this->format_member($member); } $this->CI->api_v2_response->respond($members, 200, ['count' => count($members)]); } /** * GET /api/v2/club/{user_id} * A single member of the clubstation. */ public function show($user_id) { $this->resolve_club(); $member = $this->find_member($user_id); if ($member === null) { throw new Api_v2_exception('not_found', 'User is not a member of this clubstation', 404); } $this->CI->api_v2_response->respond($this->format_member($member)); } /** * POST /api/v2/club * Add a member to the clubstation. * Body: { user_id, permission_level, notify? } */ public function create() { $this->require_write(); $club_id = $this->resolve_club(); $this->require_not_sso_managed(); $body = $this->body(); $this->require_scalar_fields($body); // The id comes from the body here, so a missing or malformed one is a // validation error rather than an unknown URL. if (!isset($body['user_id']) || !is_numeric($body['user_id'])) { throw new Api_v2_exception('validation_error', 'user_id must be a numeric user id', 400, ['field' => 'user_id']); } $user_id = (int) $body['user_id']; $this->require_manageable_member($user_id); $level = $this->parse_permission_level($body); // Changing an existing membership is what PATCH is for; creating one // twice must not silently overwrite the level already granted. if ((int) $this->CI->club_model->get_permission_noui($club_id, $user_id) > 0) { throw new Api_v2_exception('conflict', 'User is already a member of this clubstation', 409); } $this->CI->club_model->alter_member($club_id, $user_id, $level); $this->respond_member($user_id, $body, 'new_member', 201); } /** * PATCH /api/v2/club/{user_id} * Change a member's permission level. * Body: { permission_level, notify? } */ public function update($user_id) { $this->require_write(); $club_id = $this->resolve_club(); $this->require_not_sso_managed(); $body = $this->body(); $this->require_scalar_fields($body); $user_id = $this->require_manageable_member($user_id); $level = $this->parse_permission_level($body); // No upsert: adding a member is POST. if ((int) $this->CI->club_model->get_permission_noui($club_id, $user_id) === 0) { throw new Api_v2_exception('not_found', 'User is not a member of this clubstation', 404); } $this->CI->club_model->alter_member($club_id, $user_id, $level); $this->respond_member($user_id, $body, 'modified_member', 200); } /** * DELETE /api/v2/club/{user_id} * Remove a member from the clubstation, together with the API keys and rig * control sessions it created for the club (Club_model::delete_member()). */ public function delete($user_id) { $this->require_delete(); $club_id = $this->resolve_club(); $this->require_not_sso_managed(); $user_id = $this->require_manageable_member($user_id); // Without this a DELETE on a non-member would report success. if ((int) $this->CI->club_model->get_permission_noui($club_id, $user_id) === 0) { throw new Api_v2_exception('not_found', 'User is not a member of this clubstation', 404); } if (!$this->CI->club_model->delete_member($club_id, $user_id)) { throw new Api_v2_exception('internal_error', 'User could not be removed from the clubstation', 500); } $this->CI->api_v2_response->no_content(); } // --- Internal helpers -------------------------------------------------- /** * The clubstation this request acts on, after checking that the token may * act on it at all. Every handler starts here; the result is memoized, so * the internal helpers can simply ask again instead of passing it around. * * The access check runs either way - $required only decides what happens * when an administrator names no club: a 400, or a null the caller answers * differently (index() lists the clubstations instead). * * @param bool $required Whether an administrator must supply club_id. * @return int|null club_id, or null for an administrator who named none. * @throws Api_v2_exception 400 when an administrator omits club_id, * 403 when the token may not manage this club, * 404 when club_id is not a clubstation. */ protected function resolve_club($required = true) { if ($this->club_id !== null) { return $this->club_id; } $this->CI->load->model('club_model'); $requested = $this->param('club_id'); // Club token: the clubstation is the token owner, and officer level is // what separates managing members from merely logging QSOs. if ($this->club_permission() !== null) { $this->require_permission_level(9); if ($requested !== null && (int) $requested !== (int) $this->user_id()) { throw new Api_v2_exception('forbidden', 'club_id does not match the clubstation behind this token', 403); } return $this->club_id = (int) $this->user_id(); } // Personal token: only an administrator gets this far, and only by // naming the club - they have no implicit one. Judged on the creator, // which for a personal token is the owner itself. if (!$this->CI->user_model->is_admin($this->auth['created_by'])) { throw new Api_v2_exception('forbidden', 'Token is neither a club officer nor an admin', 403); } if ($requested === null) { // Left unmemoized: null is the "not resolved yet" marker. if (!$required) { return null; } throw new Api_v2_exception( 'validation_error', 'club_id is required for an administrator token', 400, ['field' => 'club_id'] ); } // A club_id that was supplied but is unusable is an error either way - // answering with the clubstation list would hide the typo. if (!is_numeric($requested) || (int) $requested < 1) { throw new Api_v2_exception( 'validation_error', 'club_id must be a numeric user id', 400, ['field' => 'club_id'] ); } $club = $this->CI->user_model->get_by_id((int) $requested); if ($club === null || $club->num_rows() === 0 || (int) $club->row()->clubstation !== 1) { throw new Api_v2_exception('not_found', 'Unknown clubstation', 404); } return $this->club_id = (int) $requested; } /** * Guard an operation behind a clubstation permission level. Only reached * for club tokens; resolve_club() handles the administrator path, where * there is no membership to grade. * * @throws Api_v2_exception 403 */ protected function require_permission_level($level) { if (!is_numeric($level) || (int) $level < 1) { throw new Api_v2_exception('internal_error', 'Invalid permission level', 500); } if ((int) $this->club_permission() !== (int) $level) { throw new Api_v2_exception('forbidden', 'Token is not a club officer', 403); } } /** * Refuse writes to a clubstation whose memberships an identity provider * owns. Those are granted and revoked on login, so a write here would be * undone without warning - the web UI disables the form for the same reason. * * @throws Api_v2_exception 409 */ protected function require_not_sso_managed() { if ($this->CI->club_model->is_sso_managed($this->resolve_club())) { throw new Api_v2_exception('conflict', 'Club membership is managed by the identity provider', 409); } } /** * Verify a user id may be managed as a member of this clubstation. * * @param mixed $user_id Id from the path or the request body. * @return int The validated user id. * @throws Api_v2_exception 400/403/404 */ protected function require_manageable_member($user_id) { if (!is_numeric($user_id) || (int) $user_id < 1) { throw new Api_v2_exception('not_found', 'Unknown user', 404); } $user_id = (int) $user_id; if ($user_id === $this->resolve_club()) { throw new Api_v2_exception('validation_error', 'A clubstation cannot be a member of itself', 400); } // An officer must not be able to lock themselves out of their own club // through the API; changing your own level stays a web UI operation. An // administrator reaches every club through the UI regardless, so the // rule would only get in their way. if ($user_id === (int) $this->auth['created_by'] && !$this->CI->user_model->is_admin($this->auth['created_by'])) { throw new Api_v2_exception('forbidden', 'Cannot modify your own club membership', 403); } $user = $this->CI->user_model->get_by_id($user_id); if ($user === null || $user->num_rows() === 0) { throw new Api_v2_exception('not_found', 'Unknown user', 404); } // Mirrors the member search of the web UI, which only offers regular // accounts (User_model::search_users() filters on clubstation = 0). if ((int) $user->row()->clubstation === 1) { throw new Api_v2_exception('validation_error', 'A clubstation cannot be a member of a clubstation', 400); } return $user_id; } /** * Read and validate the permission level from the request body against the * levels the web UI offers (Club_model::permission_levels()). * * @throws Api_v2_exception 400 */ protected function parse_permission_level($body) { $allowed = array_keys($this->CI->club_model->permission_levels()); if (!isset($body['permission_level']) || !is_numeric($body['permission_level']) || !in_array((int) $body['permission_level'], $allowed, true)) { throw new Api_v2_exception( 'validation_error', 'permission_level must be one of: ' . implode(', ', $allowed), 400, ['field' => 'permission_level', 'allowed' => $allowed] ); } return (int) $body['permission_level']; } /** * Send the response for a create/update, optionally notifying the member * first. A failed mail is reported in the meta rather than failing the * request: the permission itself was granted either way. * * @param int $user_id The member the operation acted on. * @param array $body Decoded request body (read for the notify flag). * @param string $template 'new_member' or 'modified_member'. * @param int $status 201 on create, 200 on update. */ protected function respond_member($user_id, $body, $template, $status) { $meta = null; // no notification by default if (filter_var($body['notify'] ?? false, FILTER_VALIDATE_BOOLEAN)) { $meta = ['notified' => (bool) $this->CI->club_model->notify_member($user_id, $this->resolve_club(), $template)]; } $headers = ($status === 201) ? ['Location' => base_url('index.php/api/v2/club/' . $user_id)] : []; $this->CI->api_v2_response->respond($this->format_member($this->find_member($user_id)), $status, $meta, $headers); } /** * A single member row of this clubstation, or null when the user is not a * member. Clubs have a handful of members, so filtering the existing list * query is cheaper than a new model method. * * @return object|null */ protected function find_member($user_id) { if (!is_numeric($user_id)) { return null; } foreach ($this->CI->club_model->get_club_members($this->resolve_club()) as $member) { if ((int) $member->user_id === (int) $user_id) { return $member; } } return null; } /** * Shape a clubstation row into the public API representation. Deliberately * thin: this is a directory an administrator picks a club_id from, not the * clubstation's account data. */ protected function format_clubstation($club) { return [ 'club_id' => (int) $club->user_id, 'callsign' => $club->user_callsign, 'member_count' => (int) $club->member_count, ]; } /** * Shape a club member row into the public API representation. */ protected function format_member($member) { $data = [ 'user_id' => (int) $member->user_id, 'user_firstname' => $member->user_firstname, 'user_lastname' => $member->user_lastname, 'user_locator' => $member->user_locator, 'callsign' => $member->user_callsign, 'user_name' => $member->user_name, 'user_email' => $member->user_email, 'permission_level' => (int) $member->p_level, 'user_language' => $member->user_language, ]; $removable = ['user_firstname', 'user_lastname', 'user_locator', 'user_name', 'user_email', 'user_language']; foreach (array_intersect((array) $this->CI->config->item('apiv2_hide_userdata'), $removable) as $field) { unset($data[$field]); } return $data; } }