WP_REST_Server::READABLE, 'callback' => [$this, 'list_templates'], 'permission_callback' => [$this, 'can_read'], ]); register_rest_route(self::NAMESPACE, '/quota', [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'get_quota'], 'permission_callback' => [$this, 'can_read'], ]); register_rest_route(self::NAMESPACE, '/deployments', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'list_deployments'], 'permission_callback' => [$this, 'can_read'], ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'create_deployment'], 'permission_callback' => [$this, 'can_create_deployments'], ], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'get_deployment'], 'permission_callback' => [$this, 'can_read'], ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [$this, 'delete_deployment'], 'permission_callback' => [$this, 'can_delete_deployments'], ], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/shares', [ [ 'methods' => WP_REST_Server::READABLE, 'callback' => [$this, 'list_deployment_shares'], 'permission_callback' => [$this, 'can_read'], ], [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'share_deployment'], 'permission_callback' => [$this, 'can_read'], ], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/shares/(?P\d+)', [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [$this, 'unshare_deployment'], 'permission_callback' => [$this, 'can_read'], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/start', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'start_deployment'], 'permission_callback' => [$this, 'can_start_deployments'], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/stop', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'stop_deployment'], 'permission_callback' => [$this, 'can_stop_deployments'], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/prolong', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'prolong_deployment'], 'permission_callback' => [$this, 'can_prolong_deployments'], ]); register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/refresh-ips', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'refresh_deployment_ips'], 'permission_callback' => [$this, 'can_refresh_deployment_ips'], ]); } public function can_read(): bool { return is_user_logged_in() && $this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL); } public function can_create_deployments(): bool { return $this->can_use_portal_action(SPP_Permissions::CREATE_DEPLOYMENTS); } public function can_start_deployments(): bool { return $this->can_use_portal_action(SPP_Permissions::START_DEPLOYMENTS); } public function can_stop_deployments(): bool { return $this->can_use_portal_action(SPP_Permissions::STOP_DEPLOYMENTS); } public function can_prolong_deployments(): bool { return $this->can_use_portal_action(SPP_Permissions::PROLONG_DEPLOYMENTS); } public function can_refresh_deployment_ips(): bool { return $this->can_use_portal_action(SPP_Permissions::REFRESH_DEPLOYMENT_IPS); } public function can_delete_deployments(): bool { return $this->can_use_portal_action(SPP_Permissions::DELETE_DEPLOYMENTS); } private function can_use_portal_action(string $permission): bool { return $this->can_read() && $this->permissions->current_user_has($permission); } private function can_manage_all_deployments(): bool { return $this->permissions->current_user_has(SPP_Permissions::MANAGE_ALL_DEPLOYMENTS); } public function list_templates(): WP_REST_Response { $this->sync_expirations(); return rest_ensure_response($this->repository->templates()); } public function get_quota(): WP_REST_Response { $this->sync_expirations(); return rest_ensure_response($this->repository->quota(get_current_user_id())); } public function list_deployments(): WP_REST_Response { $this->sync_expirations(); return rest_ensure_response($this->repository->deployments( get_current_user_id(), $this->can_manage_all_deployments() )); } public function get_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $deployment = $this->repository->deployment_for_user( (int) $request['id'], get_current_user_id(), $this->can_manage_all_deployments() ); if ($deployment === null) { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } return rest_ensure_response($this->deployment_response($deployment)); } public function create_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $template_id = (int) $request->get_param('templateId'); $name = sanitize_text_field((string) $request->get_param('name')); $ttl_hours = (int) $request->get_param('ttlHours'); $never_expire = (bool) $request->get_param('neverExpire'); if ($template_id < 1 || strlen($name) < 3 || strlen($name) > 160) { return new WP_Error('spp_invalid_request', 'Template and a deployment name of 3-160 characters are required.', ['status' => 400]); } $template = $this->repository->template($template_id); if ($template === null) { return new WP_Error('spp_template_not_found', 'Template not found.', ['status' => 404]); } if ($never_expire) { $ttl_hours = null; } elseif ($ttl_hours < 1) { $ttl_hours = (int) $template['default_ttl_hours']; } if ($ttl_hours !== null && $ttl_hours > 720) { return new WP_Error('spp_ttl_too_long', 'TTL cannot exceed 720 hours.', ['status' => 400]); } $quota_error = $this->validate_memory_quota((int) $template['memory_mb'], get_current_user_id()); if ($quota_error instanceof WP_Error) { return $quota_error; } $provisioning_type = $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')); try { $instance = $this->proxmox->provision_instance([ 'provisioning_type' => $provisioning_type, 'template_vm_id' => (int) $template['proxmox_template_id'], 'lxc_template_ref' => (string) ($template['proxmox_template_ref'] ?? ''), 'name' => $name, 'cpu_cores' => (int) $template['cpu_cores'], 'memory_mb' => (int) $template['memory_mb'], 'disk_gb' => (int) $template['disk_gb'], 'tags' => $this->deployment_tags(wp_get_current_user()), ]); $vm_id = (int) $instance['vm_id']; $deployment = $this->repository->create_deployment( $template, $name, $ttl_hours, $vm_id, $this->safe_ip_addresses($provisioning_type, $vm_id), get_current_user_id() ); } catch (Throwable $error) { return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); } $deployment = $this->repository->deployment_for_user( (int) $deployment['id'], get_current_user_id(), $this->can_manage_all_deployments() ); return new WP_REST_Response($this->deployment_response($deployment), 201); } private function validate_memory_quota(int $requested_memory_mb, int $actor_id): ?WP_Error { $quota = $this->repository->quota($actor_id); if ($quota['userLimitMb'] > 0 && ($quota['userUsedMb'] + $requested_memory_mb) > $quota['userLimitMb']) { return new WP_Error( 'spp_user_quota_exceeded', 'This deployment would exceed your RAM contingent.', ['status' => 403, 'quota' => $quota] ); } if ($quota['globalLimitMb'] > 0 && ($quota['globalUsedMb'] + $requested_memory_mb) > $quota['globalLimitMb']) { return new WP_Error( 'spp_global_quota_exceeded', 'This deployment would exceed the global RAM contingent.', ['status' => 403, 'quota' => $quota] ); } return null; } public function start_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->apply_lifecycle_action((int) $request['id'], 'RUNNING', 'DEPLOYMENT_STARTED', 'start'); } public function stop_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->apply_lifecycle_action((int) $request['id'], 'STOPPED', 'DEPLOYMENT_STOPPED', 'stop'); } public function delete_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->apply_lifecycle_action((int) $request['id'], 'DELETED', 'DEPLOYMENT_DELETED', 'delete'); } public function prolong_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $id = (int) $request['id']; $record = $this->repository->deployment_record($id); if ($record === null || $record['status'] === 'DELETED') { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } if (!$this->repository->user_can_access_deployment($id, get_current_user_id(), $this->can_manage_all_deployments())) { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } $ttl_hours = (int) $request->get_param('ttlHours'); $never_expire = (bool) $request->get_param('neverExpire'); $deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments()); if ($deployment === null) { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } if ($never_expire) { $ttl_hours = null; } elseif ($ttl_hours < 1) { return new WP_Error('spp_invalid_ttl', 'Choose a TTL in hours or select never expire.', ['status' => 400]); } if ($ttl_hours !== null && $ttl_hours > 720) { return new WP_Error('spp_ttl_too_long', 'TTL cannot exceed 720 hours.', ['status' => 400]); } if ($record['status'] === 'EXPIRED') { $quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], (int) $record['requested_by']); if ($quota_error instanceof WP_Error) { return $quota_error; } } $this->repository->prolong_deployment($id, $ttl_hours, get_current_user_id()); $deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments()); return rest_ensure_response($this->deployment_response($deployment)); } public function refresh_deployment_ips(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $id = (int) $request['id']; $record = $this->repository->deployment_record($id); if ($record === null || $record['status'] === 'DELETED') { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } if (!$this->repository->user_can_access_deployment($id, get_current_user_id(), $this->can_manage_all_deployments())) { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } if (empty($record['proxmox_vm_id'])) { return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox resource id.', ['status' => 409]); } try { $this->repository->update_deployment_ips( $id, $this->proxmox->get_ip_addresses( (string) ($record['provisioning_type'] ?? 'qemu'), (int) $record['proxmox_vm_id'] ), get_current_user_id() ); } catch (Throwable $error) { return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); } $deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments()); return rest_ensure_response($this->deployment_response($deployment)); } public function list_deployment_shares(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $id = (int) $request['id']; if (!$this->user_can_share_deployment($id)) { return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can view shares.', ['status' => 403]); } return rest_ensure_response($this->repository->deployment_shares($id)); } public function share_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $id = (int) $request['id']; if (!$this->user_can_share_deployment($id)) { return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can share this deployment.', ['status' => 403]); } $identifier = sanitize_text_field((string) $request->get_param('user')); $target = $this->find_share_target_user($identifier); if (!$target instanceof WP_User) { return new WP_Error('spp_user_not_found', 'User not found.', ['status' => 404]); } if ((int) $target->ID === get_current_user_id()) { return new WP_Error('spp_invalid_share_target', 'You already have access to this deployment.', ['status' => 400]); } $record = $this->repository->deployment_record($id); if ($record === null || (int) $record['requested_by'] === (int) $target->ID) { return new WP_Error('spp_invalid_share_target', 'The owner already has access to this deployment.', ['status' => 400]); } if (!$this->permissions->user_has((int) $target->ID, SPP_Permissions::VIEW_PORTAL)) { return new WP_Error('spp_user_without_portal_access', 'That user does not have portal access yet.', ['status' => 400]); } $this->repository->share_deployment($id, (int) $target->ID, get_current_user_id()); return rest_ensure_response($this->repository->deployment_shares($id)); } public function unshare_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error { $this->sync_expirations(); $id = (int) $request['id']; if (!$this->user_can_share_deployment($id)) { return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can change shares.', ['status' => 403]); } $this->repository->unshare_deployment($id, (int) $request['user_id'], get_current_user_id()); return rest_ensure_response($this->repository->deployment_shares($id)); } private function apply_lifecycle_action(int $id, string $status, string $audit_action, string $method): WP_REST_Response|WP_Error { $this->sync_expirations(); $record = $this->repository->deployment_record($id); if ($record === null || $record['status'] === 'DELETED') { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } if (!$this->repository->user_can_access_deployment($id, get_current_user_id(), $this->can_manage_all_deployments())) { return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]); } if ($method === 'delete' && !$this->user_can_delete_deployment($id)) { return new WP_Error('spp_forbidden', 'Only the owner or a deployment manager can delete this deployment.', ['status' => 403]); } if ($method === 'start' && $record['status'] === 'EXPIRED') { return new WP_Error( 'spp_expired_deployment', 'This deployment is expired. Prolong its TTL before starting it again.', ['status' => 409] ); } if (empty($record['proxmox_vm_id'])) { return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox resource id.', ['status' => 409]); } $provisioning_type = $this->normalise_template_type((string) ($record['provisioning_type'] ?? 'qemu')); try { if ($method === 'start') { $this->proxmox->start_instance($provisioning_type, (int) $record['proxmox_vm_id']); $this->repository->update_deployment_status_and_ips( $id, $status, $this->safe_ip_addresses($provisioning_type, (int) $record['proxmox_vm_id']), $audit_action, get_current_user_id() ); } elseif ($method === 'stop') { $this->proxmox->stop_instance($provisioning_type, (int) $record['proxmox_vm_id']); $this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id()); } else { $this->proxmox->delete_instance($provisioning_type, (int) $record['proxmox_vm_id']); $this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id()); } } catch (Throwable $error) { return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]); } if ($method === 'delete') { return rest_ensure_response(['deleted' => true]); } $deployment = $this->repository->deployment_for_user($id, get_current_user_id(), $this->can_manage_all_deployments()); return rest_ensure_response($this->deployment_response($deployment)); } /** * @param array|null $deployment * @return array */ private function deployment_response(?array $deployment): array { if ($deployment === null) { return []; } $id = (int) $deployment['id']; $deployment['canShare'] = $this->user_can_share_deployment($id); $deployment['canDelete'] = $this->user_can_delete_deployment($id); if ($deployment['canShare']) { $deployment['shares'] = $this->repository->deployment_shares($id); } return $deployment; } private function user_can_share_deployment(int $deployment_id): bool { $record = $this->repository->deployment_record($deployment_id); if ($record === null || $record['status'] === 'DELETED') { return false; } return $this->repository->user_owns_deployment($deployment_id, get_current_user_id()) || $this->can_manage_all_deployments(); } private function user_can_delete_deployment(int $deployment_id): bool { $record = $this->repository->deployment_record($deployment_id); if ($record === null || $record['status'] === 'DELETED') { return false; } return $this->permissions->current_user_has(SPP_Permissions::DELETE_DEPLOYMENTS) && ( $this->repository->user_owns_deployment($deployment_id, get_current_user_id()) || $this->can_manage_all_deployments() ); } private function find_share_target_user(string $identifier): ?WP_User { if ($identifier === '') { return null; } if (is_email($identifier)) { $user = get_user_by('email', $identifier); } else { $user = get_user_by('login', $identifier); } return $user instanceof WP_User ? $user : null; } private function sync_expirations(): void { $this->expiration_service->expire_due_deployments(); } /** * @return array */ private function safe_ip_addresses(string $provisioning_type, int $vm_id): array { try { return $this->proxmox->get_ip_addresses($provisioning_type, $vm_id); } catch (Throwable) { return []; } } /** * @return array */ private function deployment_tags(WP_User $user): array { $login = $user->user_login !== '' ? $user->user_login : $user->display_name; $user_tag = strtolower(sanitize_title($login)); if ($user_tag === '') { $user_tag = (string) $user->ID; } return ['support-portal', 'user-' . $user_tag]; } private function normalise_template_type(string $type): string { return strtolower($type) === 'lxc' ? 'lxc' : 'qemu'; } }