new file: CHANGELOG.md

modified:   README.md
	modified:   support-provisioning-portal/assets/portal.css
	modified:   support-provisioning-portal/assets/portal.js
	modified:   support-provisioning-portal/includes/class-spp-activator.php
	modified:   support-provisioning-portal/includes/class-spp-admin-page.php
	modified:   support-provisioning-portal/includes/class-spp-http-proxmox-client.php
	modified:   support-provisioning-portal/includes/class-spp-mock-proxmox-client.php
	new file:   support-provisioning-portal/includes/class-spp-permissions.php
	modified:   support-provisioning-portal/includes/class-spp-plugin.php
	modified:   support-provisioning-portal/includes/class-spp-repository.php
	modified:   support-provisioning-portal/includes/class-spp-rest-controller.php
	modified:   support-provisioning-portal/includes/class-spp-shortcode.php
	modified:   support-provisioning-portal/includes/interface-spp-proxmox-client.php
	modified:   support-provisioning-portal/support-provisioning-portal.php
This commit is contained in:
Sven Steinert
2026-04-24 15:13:42 +02:00
parent aee79ddbfa
commit 2c1949bf1e
15 changed files with 1900 additions and 170 deletions

View File

@@ -19,6 +19,19 @@ final class SPP_Repository
return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []);
}
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed>|null
*/
@@ -35,23 +48,35 @@ final class SPP_Repository
/**
* @return array<int, array<string, mixed>>
*/
public function deployments(): 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;
$rows = $wpdb->get_results(
"SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name
$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
WHERE d.status <> 'DELETED'
ORDER BY d.created_at DESC",
ARRAY_A
);
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 : []);
}
@@ -82,6 +107,41 @@ final class SPP_Repository
return is_array($row) ? $this->deployment_detail_dto($row) : null;
}
/**
* @return array<string, mixed>|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<string, mixed> $template
* @return array<string, mixed>
@@ -128,6 +188,10 @@ final class SPP_Repository
'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);
@@ -204,6 +268,115 @@ final class SPP_Repository
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<int, array{id:int,displayName:string,userLogin:string,userEmail:string,sharedAt:string}>
*/
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<int, array<string, mixed>>
*/
@@ -257,6 +430,146 @@ final class SPP_Repository
];
}
/**
* @param array<string, mixed> $data
*/
public function upsert_template(array $data, int $actor_id): ?array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$now = current_time('mysql');
$template_key = $this->unique_template_key((string) $data['template_key'], (int) $data['proxmox_template_id']);
$existing_id = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table} WHERE proxmox_template_id = %d OR template_key = %s ORDER BY proxmox_template_id = %d DESC LIMIT 1",
(int) $data['proxmox_template_id'],
$template_key,
(int) $data['proxmox_template_id']
)
);
$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']))),
'proxmox_template_id' => absint($data['proxmox_template_id']),
'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, [
'proxmox_template_id' => (int) $row['proxmox_template_id'],
'name' => (string) $row['name'],
]);
return $this->template_for_admin($template_id);
}
/**
* @param array<string, mixed> $data
*/
public function update_template(int $id, array $data, int $actor_id): ?array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$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']))),
'proxmox_template_id' => absint($data['proxmox_template_id']),
'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, [
'proxmox_template_id' => (int) $row['proxmox_template_id'],
'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<int, int>
*/
public function active_proxmox_template_ids(): array
{
global $wpdb;
$table = SPP_Activator::table('templates');
$ids = $wpdb->get_col("SELECT proxmox_template_id FROM {$table} WHERE is_active = 1");
return array_values(array_map('intval', is_array($ids) ? $ids : []));
}
/**
* @param array<int, string> $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);
@@ -291,6 +604,30 @@ final class SPP_Repository
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
}
/**
* @return array<string, mixed>|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, int $proxmox_template_id): string
{
$key = sanitize_title($raw_key);
if ($key === '') {
$key = 'pve-template-' . $proxmox_template_id;
}
return substr($key, 0, 80);
}
/**
* @param array<string, mixed> $metadata
*/
@@ -324,6 +661,8 @@ final class SPP_Repository
'memoryMb' => (int) $row['memory_mb'],
'diskGb' => (int) $row['disk_gb'],
'defaultTtlHours' => (int) $row['default_ttl_hours'],
'proxmoxTemplateId' => (int) $row['proxmox_template_id'],
'isActive' => (int) $row['is_active'] === 1,
];
}
@@ -338,7 +677,9 @@ final class SPP_Repository
'name' => (string) $row['name'],
'status' => (string) $row['status'],
'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'],