> */ public function templates(): array { global $wpdb; $table = SPP_Activator::table('templates'); $rows = $wpdb->get_results("SELECT * FROM {$table} WHERE is_active = 1 ORDER BY name ASC", ARRAY_A); return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []); } /** * @return array> */ public function admin_templates(): array { global $wpdb; $table = SPP_Activator::table('templates'); $rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY is_active DESC, name ASC", ARRAY_A); return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []); } /** * @return array|null */ public function template(int $id): ?array { global $wpdb; $table = SPP_Activator::table('templates'); $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND is_active = 1", $id), ARRAY_A); return is_array($row) ? $row : null; } /** * @return array> */ public function deployments(int $actor_id, bool $include_all = false): array { global $wpdb; $deployments = SPP_Activator::table('deployments'); $templates = SPP_Activator::table('templates'); $shares = SPP_Activator::table('deployment_shares'); $users = $wpdb->users; $sql = "SELECT DISTINCT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name, CASE WHEN d.requested_by = %d THEN 'owner' WHEN s.user_id IS NOT NULL THEN 'shared' ELSE 'admin' END AS access_type FROM {$deployments} d INNER JOIN {$templates} t ON t.id = d.template_id INNER JOIN {$users} u ON u.ID = d.requested_by LEFT JOIN {$shares} s ON s.deployment_id = d.id AND s.user_id = %d WHERE d.status <> 'DELETED'"; $params = [$actor_id, $actor_id]; if (!$include_all) { $sql .= ' AND (d.requested_by = %d OR s.user_id IS NOT NULL)'; $params[] = $actor_id; } $sql .= ' ORDER BY d.created_at DESC'; $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); return array_map([$this, 'deployment_summary_dto'], is_array($rows) ? $rows : []); } /** * @return array|null */ public function deployment(int $id): ?array { global $wpdb; $deployments = SPP_Activator::table('deployments'); $templates = SPP_Activator::table('templates'); $users = $wpdb->users; $row = $wpdb->get_row( $wpdb->prepare( "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name FROM {$deployments} d INNER JOIN {$templates} t ON t.id = d.template_id INNER JOIN {$users} u ON u.ID = d.requested_by WHERE d.id = %d", $id ), ARRAY_A ); return is_array($row) ? $this->deployment_detail_dto($row) : null; } /** * @return array|null */ public function deployment_for_user(int $id, int $actor_id, bool $include_all = false): ?array { global $wpdb; $deployments = SPP_Activator::table('deployments'); $templates = SPP_Activator::table('templates'); $shares = SPP_Activator::table('deployment_shares'); $users = $wpdb->users; $sql = "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name, CASE WHEN d.requested_by = %d THEN 'owner' WHEN s.user_id IS NOT NULL THEN 'shared' ELSE 'admin' END AS access_type FROM {$deployments} d INNER JOIN {$templates} t ON t.id = d.template_id INNER JOIN {$users} u ON u.ID = d.requested_by LEFT JOIN {$shares} s ON s.deployment_id = d.id AND s.user_id = %d WHERE d.id = %d AND d.status <> 'DELETED'"; $params = [$actor_id, $actor_id, $id]; if (!$include_all) { $sql .= ' AND (d.requested_by = %d OR s.user_id IS NOT NULL)'; $params[] = $actor_id; } $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); return is_array($row) ? $this->deployment_detail_dto($row) : null; } /** * @param array $template * @return array */ public function create_deployment(array $template, string $name, ?int $ttl_hours, int $vm_id, array $ip_addresses, int $actor_id): array { global $wpdb; $now = current_time('mysql'); $expires_at = $ttl_hours === null ? null : date('Y-m-d H:i:s', current_time('timestamp') + ($ttl_hours * HOUR_IN_SECONDS)); $table = SPP_Activator::table('deployments'); $wpdb->insert($table, [ 'name' => $name, 'status' => 'STOPPED', 'provisioning_type' => $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')), 'proxmox_vm_id' => $vm_id, 'ip_addresses' => wp_json_encode(array_values($ip_addresses)), 'expires_at' => $expires_at, 'created_at' => $now, 'updated_at' => $now, 'template_id' => (int) $template['id'], 'requested_by' => $actor_id, ]); $deployment_id = (int) $wpdb->insert_id; $this->audit('DEPLOYMENT_CREATED', 'deployment', $deployment_id, $actor_id, [ 'template_id' => (int) $template['id'], 'provisioning_type' => $this->normalise_template_type((string) ($template['provisioning_type'] ?? 'qemu')), 'proxmox_vm_id' => $vm_id, 'ip_addresses' => $ip_addresses, 'ttl_hours' => $ttl_hours, 'never_expire' => $ttl_hours === null, ]); return $this->deployment($deployment_id); } public function update_deployment_status(int $id, string $status, string $action, int $actor_id): ?array { global $wpdb; $table = SPP_Activator::table('deployments'); $wpdb->update($table, [ 'status' => $status, 'updated_at' => current_time('mysql'), ], ['id' => $id]); if ($status === 'DELETED') { $wpdb->delete(SPP_Activator::table('deployment_shares'), ['deployment_id' => $id]); } $this->audit($action, 'deployment', $id, $actor_id, ['status' => $status]); return $this->deployment($id); } public function update_deployment_status_and_ips(int $id, string $status, array $ip_addresses, string $action, int $actor_id): ?array { global $wpdb; $table = SPP_Activator::table('deployments'); $wpdb->update($table, [ 'status' => $status, 'ip_addresses' => wp_json_encode(array_values($ip_addresses)), 'updated_at' => current_time('mysql'), ], ['id' => $id]); $this->audit($action, 'deployment', $id, $actor_id, [ 'status' => $status, 'ip_addresses' => $ip_addresses, ]); return $this->deployment($id); } public function update_deployment_ips(int $id, array $ip_addresses, int $actor_id): ?array { global $wpdb; $table = SPP_Activator::table('deployments'); $wpdb->update($table, [ 'ip_addresses' => wp_json_encode(array_values($ip_addresses)), 'updated_at' => current_time('mysql'), ], ['id' => $id]); $this->audit('DEPLOYMENT_IPS_REFRESHED', 'deployment', $id, $actor_id, [ 'ip_addresses' => $ip_addresses, ]); return $this->deployment($id); } public function prolong_deployment(int $id, ?int $ttl_hours, int $actor_id): ?array { global $wpdb; $expires_at = $ttl_hours === null ? null : date('Y-m-d H:i:s', current_time('timestamp') + ($ttl_hours * HOUR_IN_SECONDS)); $table = SPP_Activator::table('deployments'); $wpdb->update($table, [ 'status' => 'STOPPED', 'expires_at' => $expires_at, 'error_message' => null, 'updated_at' => current_time('mysql'), ], ['id' => $id]); $this->audit('DEPLOYMENT_PROLONGED', 'deployment', $id, $actor_id, [ 'ttl_hours' => $ttl_hours, 'never_expire' => $ttl_hours === null, ]); return $this->deployment($id); } /** * @return array|null */ public function deployment_record(int $id): ?array { global $wpdb; $table = SPP_Activator::table('deployments'); $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A); return is_array($row) ? $row : null; } public function user_can_access_deployment(int $deployment_id, int $actor_id, bool $include_all = false): bool { if ($include_all) { return $this->deployment_record($deployment_id) !== null; } return $this->user_owns_deployment($deployment_id, $actor_id) || $this->deployment_is_shared_with_user($deployment_id, $actor_id); } public function user_owns_deployment(int $deployment_id, int $actor_id): bool { global $wpdb; $table = SPP_Activator::table('deployments'); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE id = %d AND requested_by = %d", $deployment_id, $actor_id ) ) > 0; } private function deployment_is_shared_with_user(int $deployment_id, int $actor_id): bool { global $wpdb; $table = SPP_Activator::table('deployment_shares'); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE deployment_id = %d AND user_id = %d", $deployment_id, $actor_id ) ) > 0; } /** * @return array */ public function deployment_shares(int $deployment_id): array { global $wpdb; $shares = SPP_Activator::table('deployment_shares'); $users = $wpdb->users; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT u.ID AS id, u.display_name, u.user_login, u.user_email, s.created_at FROM {$shares} s INNER JOIN {$users} u ON u.ID = s.user_id WHERE s.deployment_id = %d ORDER BY u.display_name ASC, u.user_login ASC", $deployment_id ), ARRAY_A ); return array_map(static function (array $row): array { return [ 'id' => (int) $row['id'], 'displayName' => (string) $row['display_name'], 'userLogin' => (string) $row['user_login'], 'userEmail' => (string) $row['user_email'], 'sharedAt' => (string) $row['created_at'], ]; }, is_array($rows) ? $rows : []); } public function share_deployment(int $deployment_id, int $target_user_id, int $actor_id): void { global $wpdb; $record = $this->deployment_record($deployment_id); if ($record === null || (int) $record['requested_by'] === $target_user_id) { return; } $table = SPP_Activator::table('deployment_shares'); $wpdb->replace($table, [ 'deployment_id' => $deployment_id, 'user_id' => $target_user_id, 'created_by' => $actor_id, 'created_at' => current_time('mysql'), ]); $this->audit('DEPLOYMENT_SHARED', 'deployment', $deployment_id, $actor_id, [ 'shared_with_user_id' => $target_user_id, ]); } public function unshare_deployment(int $deployment_id, int $target_user_id, int $actor_id): void { global $wpdb; $table = SPP_Activator::table('deployment_shares'); $wpdb->delete($table, [ 'deployment_id' => $deployment_id, 'user_id' => $target_user_id, ]); $this->audit('DEPLOYMENT_UNSHARED', 'deployment', $deployment_id, $actor_id, [ 'unshared_user_id' => $target_user_id, ]); } /** * @return array> */ public function due_for_expiration(): array { global $wpdb; $table = SPP_Activator::table('deployments'); $now = current_time('mysql'); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE expires_at IS NOT NULL AND expires_at <= %s AND status IN ('PROVISIONING', 'STOPPED', 'RUNNING')", $now ), ARRAY_A ); return is_array($rows) ? $rows : []; } public function mark_deployment_expired(int $id, ?string $stop_error): void { global $wpdb; $table = SPP_Activator::table('deployments'); $wpdb->update($table, [ 'status' => 'EXPIRED', 'error_message' => $stop_error, 'updated_at' => current_time('mysql'), ], ['id' => $id]); $this->audit('DEPLOYMENT_EXPIRED', 'deployment', $id, 0, [ 'stopped' => $stop_error === null, 'stop_error' => $stop_error, ]); } /** * @return array{userUsedMb:int,userLimitMb:int,globalUsedMb:int,globalLimitMb:int} */ public function quota(int $actor_id): array { return [ 'userUsedMb' => $this->allocated_memory_mb($actor_id), 'userLimitMb' => $this->user_memory_limit_mb($actor_id), 'globalUsedMb' => $this->allocated_memory_mb(null), 'globalLimitMb' => max(0, (int) get_option('spp_quota_global_memory_mb', 0)), ]; } /** * @param array $data */ public function upsert_template(array $data, int $actor_id): ?array { global $wpdb; $table = SPP_Activator::table('templates'); $now = current_time('mysql'); $provisioning_type = $this->normalise_template_type((string) ($data['provisioning_type'] ?? 'qemu')); $proxmox_template_id = $provisioning_type === 'qemu' ? absint($data['proxmox_template_id']) : 0; $proxmox_template_ref = $provisioning_type === 'lxc' ? sanitize_text_field((string) ($data['proxmox_template_ref'] ?? '')) : null; $template_key = $this->unique_template_key((string) $data['template_key'], $provisioning_type, $proxmox_template_id, (string) $proxmox_template_ref); $existing_id = $this->template_existing_id($provisioning_type, $proxmox_template_id, (string) $proxmox_template_ref, $template_key); $row = [ 'template_key' => $template_key, 'name' => sanitize_text_field((string) $data['name']), 'description' => sanitize_textarea_field((string) $data['description']), 'os_type' => strtoupper(sanitize_key((string) $data['os_type'])), 'cpu_cores' => max(1, absint($data['cpu_cores'])), 'memory_mb' => max(128, absint($data['memory_mb'])), 'disk_gb' => max(1, absint($data['disk_gb'])), 'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))), 'provisioning_type' => $provisioning_type, 'proxmox_template_id' => $proxmox_template_id, 'proxmox_template_ref' => $proxmox_template_ref, 'is_active' => 1, 'updated_at' => $now, ]; if ($existing_id > 0) { $wpdb->update($table, $row, ['id' => $existing_id]); $template_id = $existing_id; $action = 'TEMPLATE_UPDATED'; } else { $wpdb->insert($table, array_merge($row, ['created_at' => $now])); $template_id = (int) $wpdb->insert_id; $action = 'TEMPLATE_IMPORTED'; } $this->audit($action, 'template', $template_id, $actor_id, [ 'provisioning_type' => (string) $row['provisioning_type'], 'proxmox_template_id' => (int) $row['proxmox_template_id'], 'proxmox_template_ref' => (string) ($row['proxmox_template_ref'] ?? ''), 'name' => (string) $row['name'], ]); return $this->template_for_admin($template_id); } /** * @param array $data */ public function update_template(int $id, array $data, int $actor_id): ?array { global $wpdb; $table = SPP_Activator::table('templates'); $provisioning_type = $this->normalise_template_type((string) ($data['provisioning_type'] ?? 'qemu')); $proxmox_template_id = $provisioning_type === 'qemu' ? absint($data['proxmox_template_id']) : 0; $proxmox_template_ref = $provisioning_type === 'lxc' ? sanitize_text_field((string) ($data['proxmox_template_ref'] ?? '')) : null; $row = [ 'name' => sanitize_text_field((string) $data['name']), 'description' => sanitize_textarea_field((string) $data['description']), 'os_type' => strtoupper(sanitize_key((string) $data['os_type'])), 'cpu_cores' => max(1, absint($data['cpu_cores'])), 'memory_mb' => max(128, absint($data['memory_mb'])), 'disk_gb' => max(1, absint($data['disk_gb'])), 'default_ttl_hours' => max(1, min(720, absint($data['default_ttl_hours']))), 'provisioning_type' => $provisioning_type, 'proxmox_template_id' => $proxmox_template_id, 'proxmox_template_ref' => $proxmox_template_ref, 'is_active' => empty($data['is_active']) ? 0 : 1, 'updated_at' => current_time('mysql'), ]; $wpdb->update($table, $row, ['id' => $id]); $this->audit('TEMPLATE_UPDATED', 'template', $id, $actor_id, [ 'provisioning_type' => (string) $row['provisioning_type'], 'proxmox_template_id' => (int) $row['proxmox_template_id'], 'proxmox_template_ref' => (string) ($row['proxmox_template_ref'] ?? ''), 'name' => (string) $row['name'], 'is_active' => (int) $row['is_active'], ]); return $this->template_for_admin($id); } public function deactivate_template(int $id, int $actor_id): ?array { global $wpdb; $table = SPP_Activator::table('templates'); $wpdb->update($table, [ 'is_active' => 0, 'updated_at' => current_time('mysql'), ], ['id' => $id]); $this->audit('TEMPLATE_REMOVED', 'template', $id, $actor_id, []); return $this->template_for_admin($id); } /** * @return array */ public function active_template_identity_keys(): array { global $wpdb; $table = SPP_Activator::table('templates'); $rows = $wpdb->get_results( "SELECT provisioning_type, proxmox_template_id, proxmox_template_ref FROM {$table} WHERE is_active = 1", ARRAY_A ); $keys = []; foreach (is_array($rows) ? $rows : [] as $row) { $keys[] = $this->template_identity_key( (string) ($row['provisioning_type'] ?? 'qemu'), (int) ($row['proxmox_template_id'] ?? 0), (string) ($row['proxmox_template_ref'] ?? '') ); } return array_values(array_unique($keys)); } /** * @param array $permissions */ public function update_user_access(int $user_id, array $permissions, ?int $memory_quota_mb, int $actor_id): void { if ($user_id < 1) { return; } $permissions = SPP_Permissions::sanitize_permissions($permissions); if (empty($permissions)) { delete_user_meta($user_id, SPP_Permissions::META_KEY); } else { update_user_meta($user_id, SPP_Permissions::META_KEY, $permissions); } if ($memory_quota_mb === null) { delete_user_meta($user_id, 'spp_memory_quota_mb'); } else { update_user_meta($user_id, 'spp_memory_quota_mb', max(0, $memory_quota_mb)); } $this->audit('USER_ACCESS_UPDATED', 'user', $user_id, $actor_id, [ 'permissions' => $permissions, 'memory_quota_mb' => $memory_quota_mb, ]); } private function user_memory_limit_mb(int $actor_id): int { $user_limit = get_user_meta($actor_id, 'spp_memory_quota_mb', true); if ($user_limit !== '') { return max(0, (int) $user_limit); } return max(0, (int) get_option('spp_quota_user_memory_mb', 0)); } private function allocated_memory_mb(?int $actor_id): int { global $wpdb; $deployments = SPP_Activator::table('deployments'); $templates = SPP_Activator::table('templates'); $active_statuses = ['PROVISIONING', 'STOPPED', 'RUNNING', 'DELETING']; $placeholders = implode(',', array_fill(0, count($active_statuses), '%s')); $sql = "SELECT COALESCE(SUM(t.memory_mb), 0) FROM {$deployments} d INNER JOIN {$templates} t ON t.id = d.template_id WHERE d.status IN ({$placeholders})"; $params = $active_statuses; if ($actor_id !== null) { $sql .= ' AND d.requested_by = %d'; $params[] = $actor_id; } return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); } /** * @return array|null */ private function template_for_admin(int $id): ?array { global $wpdb; $table = SPP_Activator::table('templates'); $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A); return is_array($row) ? $this->template_dto($row) : null; } private function unique_template_key(string $raw_key, string $provisioning_type, int $proxmox_template_id, string $proxmox_template_ref): string { $key = sanitize_title($raw_key); if ($key === '') { $key = $this->template_identity_key($provisioning_type, $proxmox_template_id, $proxmox_template_ref); } return substr($key, 0, 80); } private function template_existing_id(string $provisioning_type, int $proxmox_template_id, string $proxmox_template_ref, string $template_key): int { global $wpdb; $table = SPP_Activator::table('templates'); if ($provisioning_type === 'lxc') { return (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE (provisioning_type = 'lxc' AND proxmox_template_ref = %s) OR template_key = %s LIMIT 1", $proxmox_template_ref, $template_key ) ); } return (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE (provisioning_type = 'qemu' AND proxmox_template_id = %d) OR template_key = %s LIMIT 1", $proxmox_template_id, $template_key ) ); } private function template_identity_key(string $provisioning_type, int $proxmox_template_id, string $proxmox_template_ref): string { return $this->normalise_template_type($provisioning_type) === 'lxc' ? 'lxc:' . $proxmox_template_ref : 'qemu:' . $proxmox_template_id; } private function normalise_template_type(string $type): string { return strtolower($type) === 'lxc' ? 'lxc' : 'qemu'; } /** * @param array $metadata */ private function audit(string $action, string $entity_type, int $entity_id, int $actor_id, array $metadata): void { global $wpdb; $wpdb->insert(SPP_Activator::table('audit_logs'), [ 'action' => $action, 'entity_type' => $entity_type, 'entity_id' => $entity_id, 'actor_id' => $actor_id, 'metadata' => wp_json_encode($metadata), 'created_at' => current_time('mysql'), ]); } /** * @param array $row * @return array */ private function template_dto(array $row): array { return [ 'id' => (int) $row['id'], 'key' => (string) $row['template_key'], 'name' => (string) $row['name'], 'description' => (string) $row['description'], 'osType' => (string) $row['os_type'], 'cpuCores' => (int) $row['cpu_cores'], 'memoryMb' => (int) $row['memory_mb'], 'diskGb' => (int) $row['disk_gb'], 'defaultTtlHours' => (int) $row['default_ttl_hours'], 'provisioningType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')), 'proxmoxTemplateId' => (int) $row['proxmox_template_id'], 'proxmoxTemplateRef' => (string) ($row['proxmox_template_ref'] ?? ''), 'isActive' => (int) $row['is_active'] === 1, ]; } /** * @param array $row * @return array */ private function deployment_summary_dto(array $row): array { return [ 'id' => (int) $row['id'], 'name' => (string) $row['name'], 'status' => (string) $row['status'], 'provisioningType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')), 'templateName' => (string) $row['template_name'], 'requestedById' => (int) $row['requested_by'], 'requestedByName' => (string) $row['requested_by_name'], 'accessType' => isset($row['access_type']) ? (string) $row['access_type'] : null, 'ipAddresses' => $this->ip_addresses_from_row($row), 'expiresAt' => $row['expires_at'] === null ? null : (string) $row['expires_at'], 'createdAt' => (string) $row['created_at'], ]; } /** * @param array $row * @return array */ private function deployment_detail_dto(array $row): array { return array_merge($this->deployment_summary_dto($row), [ 'templateId' => (int) $row['template_id'], 'proxmoxVmId' => isset($row['proxmox_vm_id']) ? (int) $row['proxmox_vm_id'] : null, 'proxmoxResourceType' => $this->normalise_template_type((string) ($row['provisioning_type'] ?? 'qemu')), 'proxmoxResourceId' => isset($row['proxmox_vm_id']) ? (int) $row['proxmox_vm_id'] : null, 'cpuCores' => (int) $row['cpu_cores'], 'memoryMb' => (int) $row['memory_mb'], 'diskGb' => (int) $row['disk_gb'], 'errorMessage' => $row['error_message'] === null ? null : (string) $row['error_message'], ]); } /** * @param array $row * @return array */ private function ip_addresses_from_row(array $row): array { if (empty($row['ip_addresses'])) { return []; } $decoded = json_decode((string) $row['ip_addresses'], true); return is_array($decoded) ? array_values(array_map('strval', $decoded)) : []; } }