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

@@ -16,6 +16,11 @@
align-items: start;
}
.spp-admin-stack {
display: grid;
gap: 20px;
}
.spp-settings,
.spp-panel,
.spp-card {
@@ -28,6 +33,14 @@
padding: 18px;
}
.spp-admin-notice {
padding: 16px;
}
.spp-admin-notice p {
margin: 0;
}
.spp-settings label {
display: grid;
gap: 6px;
@@ -37,6 +50,7 @@
.spp-settings input,
.spp-settings select,
.spp-settings textarea,
.spp-input,
.spp-select {
min-height: 38px;
@@ -48,6 +62,10 @@
padding: 7px 10px;
}
.spp-settings textarea {
min-height: 72px;
}
.spp-header {
display: flex;
flex-wrap: wrap;
@@ -166,6 +184,21 @@
color: #92400e;
}
.spp-badge.OWNER {
background: #dbeafe;
color: #1e40af;
}
.spp-badge.SHARED {
background: #ede9fe;
color: #5b21b6;
}
.spp-badge.ADMIN {
background: #e0f2fe;
color: #075985;
}
.spp-badge.FAILED,
.spp-error {
background: #fee2e2;
@@ -242,8 +275,51 @@
margin: 0;
}
.spp-share-panel {
border-top: 1px solid var(--spp-line);
display: grid;
gap: 12px;
margin-top: 18px;
max-width: 640px;
padding-top: 16px;
}
.spp-share-panel h3,
.spp-share-panel p {
margin: 0;
}
.spp-share-list {
display: grid;
gap: 8px;
}
.spp-share-row {
align-items: center;
border: 1px solid var(--spp-line);
border-radius: 6px;
display: flex;
gap: 10px;
justify-content: space-between;
padding: 10px;
}
.spp-share-row small {
color: var(--spp-muted);
display: block;
margin-top: 2px;
}
.spp-share-form {
align-items: end;
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.spp-form label,
.spp-prolong-form label {
.spp-prolong-form label,
.spp-share-form label {
display: grid;
gap: 6px;
font-weight: 700;
@@ -264,9 +340,148 @@
padding: 10px 12px;
}
.spp-user-access {
margin-top: 20px;
}
.spp-template-admin {
margin-top: 20px;
}
.spp-template-admin h3 {
margin: 18px 0 10px;
}
.spp-section-header {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
margin-bottom: 14px;
}
.spp-section-header h2 {
margin: 0 0 4px;
}
.spp-user-search {
align-items: center;
display: flex;
gap: 8px;
}
.spp-user-search input {
min-height: 32px;
}
.spp-user-access-table td {
vertical-align: top;
}
.spp-user-login {
color: var(--spp-muted);
display: block;
margin-top: 4px;
}
.spp-permission-groups {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.spp-permission-groups fieldset {
border: 1px solid var(--spp-line);
border-radius: 6px;
margin: 0;
padding: 10px;
}
.spp-permission-groups legend {
font-weight: 700;
padding: 0 4px;
}
.spp-permission-groups label {
align-items: flex-start;
display: flex !important;
font-weight: 400;
gap: 6px;
margin: 8px 0 0;
}
.spp-permission-groups input[type="checkbox"] {
margin: 2px 0 0;
min-height: auto;
padding: 0;
}
.spp-template-list,
.spp-pve-template-grid {
display: grid;
gap: 12px;
}
.spp-pve-template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.spp-template-row,
.spp-pve-template {
border: 1px solid var(--spp-line);
border-radius: 6px;
display: grid;
gap: 12px;
margin: 0;
padding: 14px;
}
.spp-template-row-head {
align-items: flex-start;
display: flex;
gap: 10px;
justify-content: space-between;
}
.spp-template-row-head strong,
.spp-template-row-head span {
display: block;
}
.spp-template-row-head span {
color: var(--spp-muted);
font-size: 13px;
margin-top: 2px;
}
.spp-template-fields {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.spp-template-fields label {
margin: 0;
}
.spp-template-description {
grid-column: 1 / -1;
}
.spp-template-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@media (max-width: 960px) {
.spp-admin-grid,
.spp-grid {
.spp-grid,
.spp-permission-groups,
.spp-pve-template-grid,
.spp-template-fields,
.spp-share-form {
grid-template-columns: 1fr;
}
}

View File

@@ -4,6 +4,8 @@
roots.forEach((root) => {
const api = root.dataset.restUrl;
const nonce = root.dataset.nonce;
const permissions = new Set(JSON.parse(root.dataset.permissions || "[]"));
const can = (permission) => permissions.has(permission);
let state = {
view: "deployments",
deployments: [],
@@ -102,8 +104,58 @@
render();
};
const shareDeployment = async (event, id) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await request(`/deployments/${id}/shares`, {
method: "POST",
body: JSON.stringify({
user: String(form.get("user"))
})
});
state.selectedDeployment = await request(`/deployments/${id}`);
state.error = null;
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const removeShare = async (id, userId) => {
try {
await request(`/deployments/${id}/shares/${userId}`, { method: "DELETE" });
state.selectedDeployment = await request(`/deployments/${id}`);
state.error = null;
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const statusBadge = (status) => `<span class="spp-badge ${status}">${status.replace("_", " ")}</span>`;
const accessLabel = (deployment) => {
if (deployment.accessType === "shared") {
return '<span class="spp-badge SHARED">SHARED</span>';
}
if (deployment.accessType === "admin") {
return '<span class="spp-badge ADMIN">ADMIN</span>';
}
return '<span class="spp-badge OWNER">OWNER</span>';
};
const actionButton = (permission, action, label, id, className = "", disabled = false) => {
if (!can(permission)) {
return "";
}
return `<button class="spp-button ${className}" data-action="${action}" data-id="${id}" ${disabled ? "disabled" : ""} type="button">${label}</button>`;
};
const ipList = (ips) => {
if (!Array.isArray(ips) || ips.length === 0) {
return "Pending";
@@ -149,7 +201,7 @@
<div class="spp-tabs">
<button class="spp-button" data-view="deployments" type="button">Deployments</button>
<button class="spp-button" data-view="templates" type="button">Templates</button>
<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button>
${can("create_deployments") ? '<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button>' : ""}
</div>
</div>
`;
@@ -163,13 +215,14 @@
<div class="spp-panel">
<table class="spp-table">
<thead>
<tr><th>Name</th><th>Status</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
<tr><th>Name</th><th>Status</th><th>Access</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
</thead>
<tbody>
${state.deployments.map((deployment) => `
<tr>
<td><strong>${escapeHtml(deployment.name)}</strong></td>
<td>${statusBadge(deployment.status)}</td>
<td>${accessLabel(deployment)}</td>
<td>${escapeHtml(deployment.templateName)}</td>
<td>${ipList(deployment.ipAddresses)}</td>
<td>${dateTime(deployment.expiresAt)}</td>
@@ -200,28 +253,34 @@
</div>
`;
const createView = () => `
<div class="spp-panel" style="padding:16px;">
<form class="spp-form" id="spp-create-form">
<label>Template
<select class="spp-select" name="templateId" required>
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
</select>
</label>
<label>Deployment name
<input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842">
</label>
<label>TTL hours
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default">
</label>
<label class="spp-check">
<input name="neverExpire" type="checkbox" value="1">
<span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button>
</form>
</div>
`;
const createView = () => {
if (!can("create_deployments")) {
return '<div class="spp-panel"><p style="padding:16px;margin:0;">You do not have permission to create deployments.</p></div>';
}
return `
<div class="spp-panel" style="padding:16px;">
<form class="spp-form" id="spp-create-form">
<label>Template
<select class="spp-select" name="templateId" required>
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
</select>
</label>
<label>Deployment name
<input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842">
</label>
<label>TTL hours
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default">
</label>
<label class="spp-check">
<input name="neverExpire" type="checkbox" value="1">
<span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button>
</form>
</div>
`;
};
const detailView = () => {
const deployment = state.selectedDeployment;
@@ -229,6 +288,8 @@
return deploymentsView();
}
const shares = Array.isArray(deployment.shares) ? deployment.shares : [];
return `
<div class="spp-panel" style="padding:16px;">
${deployment.status === "EXPIRED" ? `
@@ -244,15 +305,16 @@
${statusBadge(deployment.status)}
</div>
<div class="spp-actions">
<button class="spp-button spp-button-primary" data-action="start" data-id="${deployment.id}" ${deployment.status === "RUNNING" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Start</button>
<button class="spp-button" data-action="stop" data-id="${deployment.id}" ${deployment.status === "STOPPED" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Stop</button>
<button class="spp-button" data-action="refresh-ips" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Refresh IPs</button>
<button class="spp-button spp-button-danger" data-action="delete" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Delete</button>
${actionButton("start_deployments", "start", "Start", deployment.id, "spp-button-primary", deployment.status === "RUNNING" || deployment.status === "EXPIRED")}
${actionButton("stop_deployments", "stop", "Stop", deployment.id, "", deployment.status === "STOPPED" || deployment.status === "EXPIRED")}
${actionButton("refresh_deployment_ips", "refresh-ips", "Refresh IPs", deployment.id, "", deployment.status === "DELETED")}
${deployment.canDelete ? actionButton("delete_deployments", "delete", "Delete", deployment.id, "spp-button-danger", deployment.status === "DELETED") : ""}
</div>
</div>
<div class="spp-meta">
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span>
<span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span>
<span>Access<strong>${deployment.accessType ? escapeHtml(deployment.accessType) : "Owner"}</strong></span>
<span>Proxmox VM ID<strong>${deployment.proxmoxVmId || "Pending"}</strong></span>
<span>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span>
<span>CPU<strong>${deployment.cpuCores} cores</strong></span>
@@ -261,17 +323,41 @@
<span>Created<strong>${dateTime(deployment.createdAt)}</strong></span>
<span>Expires<strong>${dateTime(deployment.expiresAt)}</strong></span>
</div>
<form class="spp-prolong-form" data-prolong-form="${deployment.id}">
<h3>Prolong TTL</h3>
<label>TTL hours
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now">
</label>
<label class="spp-check">
<input name="neverExpire" type="checkbox" value="1">
<span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Prolong</button>
</form>
${can("prolong_deployments") ? `
<form class="spp-prolong-form" data-prolong-form="${deployment.id}">
<h3>Prolong TTL</h3>
<label>TTL hours
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now">
</label>
<label class="spp-check">
<input name="neverExpire" type="checkbox" value="1">
<span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Prolong</button>
</form>
` : ""}
${deployment.canShare ? `
<div class="spp-share-panel">
<h3>Shared Access</h3>
<div class="spp-share-list">
${shares.length === 0 ? '<p>No users have shared access.</p>' : shares.map((share) => `
<div class="spp-share-row">
<span>
<strong>${escapeHtml(share.displayName || share.userLogin)}</strong>
<small>${escapeHtml(share.userLogin)}${share.userEmail ? ` - ${escapeHtml(share.userEmail)}` : ""}</small>
</span>
<button class="spp-button" data-remove-share="${share.id}" data-id="${deployment.id}" type="button">Remove</button>
</div>
`).join("")}
</div>
<form class="spp-share-form" data-share-form="${deployment.id}">
<label>Share with user login or email
<input class="spp-input" name="user" required>
</label>
<button class="spp-button spp-button-primary" type="submit">Share</button>
</form>
</div>
` : ""}
</div>
`;
};
@@ -310,6 +396,10 @@
button.addEventListener("click", () => lifecycle(Number(button.dataset.id), button.dataset.action));
});
root.querySelectorAll("[data-remove-share]").forEach((button) => {
button.addEventListener("click", () => removeShare(Number(button.dataset.id), Number(button.dataset.removeShare)));
});
const form = root.querySelector("#spp-create-form");
if (form) {
form.addEventListener("submit", createDeployment);
@@ -338,6 +428,10 @@
});
}
});
root.querySelectorAll("[data-share-form]").forEach((form) => {
form.addEventListener("submit", (event) => shareDeployment(event, Number(form.dataset.shareForm)));
});
};
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({

View File

@@ -6,7 +6,7 @@ if (!defined('ABSPATH')) {
final class SPP_Activator
{
private const DB_VERSION = '0.3.0';
private const DB_VERSION = '0.6.0';
public static function activate(): void
{
@@ -50,6 +50,7 @@ final class SPP_Activator
$charset_collate = $wpdb->get_charset_collate();
$templates = self::table('templates');
$deployments = self::table('deployments');
$deployment_shares = self::table('deployment_shares');
$audit_logs = self::table('audit_logs');
dbDelta("CREATE TABLE {$templates} (
@@ -96,6 +97,18 @@ final class SPP_Activator
$wpdb->query("ALTER TABLE {$deployments} ADD COLUMN ip_addresses longtext NULL AFTER proxmox_vm_id");
}
dbDelta("CREATE TABLE {$deployment_shares} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
deployment_id bigint(20) unsigned NOT NULL,
user_id bigint(20) unsigned NOT NULL,
created_by bigint(20) unsigned NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY deployment_user (deployment_id, user_id),
KEY deployment_id (deployment_id),
KEY user_id (user_id)
) {$charset_collate};");
dbDelta("CREATE TABLE {$audit_logs} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
action varchar(80) NOT NULL,
@@ -178,7 +191,6 @@ final class SPP_Activator
]);
if ($exists > 0) {
$wpdb->update($table, $data, ['id' => $exists]);
continue;
}

View File

@@ -6,15 +6,20 @@ if (!defined('ABSPATH')) {
final class SPP_Admin_Page
{
public function __construct(
private SPP_Repository $repository,
private SPP_Permissions $permissions,
private SPP_Proxmox_Client $proxmox
) {
}
public function register_hooks(): void
{
add_action('admin_menu', [$this, 'register_menu']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('show_user_profile', [$this, 'render_user_quota_field']);
add_action('edit_user_profile', [$this, 'render_user_quota_field']);
add_action('personal_options_update', [$this, 'save_user_quota_field']);
add_action('edit_user_profile_update', [$this, 'save_user_quota_field']);
add_action('admin_post_spp_save_settings', [$this, 'save_settings']);
add_action('admin_post_spp_save_user_access', [$this, 'save_user_access']);
add_action('admin_post_spp_save_template', [$this, 'save_template']);
}
public function register_menu(): void
@@ -22,7 +27,7 @@ final class SPP_Admin_Page
add_menu_page(
'Support Provisioning',
'Support Provisioning',
'edit_posts',
'exist',
'support-provisioning-portal',
[$this, 'render'],
'dashicons-cloud',
@@ -30,29 +35,6 @@ final class SPP_Admin_Page
);
}
public function register_settings(): void
{
register_setting('spp_settings', 'spp_proxmox_mode', [
'type' => 'string',
'sanitize_callback' => static fn($value) => $value === 'http' ? 'http' : 'mock',
'default' => 'mock',
]);
register_setting('spp_settings', 'spp_proxmox_base_url', ['type' => 'string', 'sanitize_callback' => 'esc_url_raw']);
register_setting('spp_settings', 'spp_proxmox_token_id', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']);
register_setting('spp_settings', 'spp_proxmox_token_secret', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']);
register_setting('spp_settings', 'spp_proxmox_node', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'pve-01']);
register_setting('spp_settings', 'spp_quota_user_memory_mb', [
'type' => 'integer',
'sanitize_callback' => static fn($value) => max(0, absint($value)),
'default' => 0,
]);
register_setting('spp_settings', 'spp_quota_global_memory_mb', [
'type' => 'integer',
'sanitize_callback' => static fn($value) => max(0, absint($value)),
'default' => 0,
]);
}
public function enqueue_assets(string $hook): void
{
if ($hook !== 'toplevel_page_support-provisioning-portal') {
@@ -65,17 +47,81 @@ final class SPP_Admin_Page
public function render(): void
{
if (!current_user_can('edit_posts')) {
if (!$this->permissions->current_user_has_any()) {
wp_die(esc_html__('You do not have permission to access this page.', 'support-provisioning-portal'));
}
$can_view_portal = $this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL);
$can_manage_templates = $this->permissions->current_user_has(SPP_Permissions::MANAGE_TEMPLATES);
$can_manage_settings = $this->permissions->current_user_has(SPP_Permissions::MANAGE_SETTINGS);
$can_manage_permissions = $this->permissions->current_user_has(SPP_Permissions::MANAGE_PERMISSIONS);
?>
<div class="wrap spp-admin-wrap">
<h1><?php echo esc_html__('Support Provisioning Portal', 'support-provisioning-portal'); ?></h1>
<div class="spp-admin-grid">
<?php $this->render_app_root(); ?>
<?php $this->render_settings(); ?>
<?php $this->render_notices(); ?>
<div class="<?php echo esc_attr($can_view_portal && $can_manage_settings ? 'spp-admin-grid' : 'spp-admin-stack'); ?>">
<div>
<?php
if ($can_view_portal) {
$this->render_app_root();
} else {
$this->render_admin_only_notice();
}
?>
</div>
<?php if ($can_manage_settings) : ?>
<div class="spp-admin-side">
<?php $this->render_settings(); ?>
</div>
<?php endif; ?>
</div>
<?php
if ($can_manage_templates) {
$this->render_template_management();
}
if ($can_manage_permissions) {
$this->render_user_access_management();
}
?>
</div>
<?php
}
private function render_notices(): void
{
$notice = isset($_GET['spp_notice']) ? sanitize_key((string) wp_unslash($_GET['spp_notice'])) : '';
if ($notice === '') {
return;
}
$messages = [
'settings_saved' => __('Settings saved.', 'support-provisioning-portal'),
'template_saved' => __('Template saved.', 'support-provisioning-portal'),
'template_removed' => __('Template removed from new provisioning.', 'support-provisioning-portal'),
'template_error' => __('Template could not be saved. Check the fields and confirm the VMID exists as a Proxmox QEMU template on the configured node.', 'support-provisioning-portal'),
'user_access_saved' => __('User rights saved.', 'support-provisioning-portal'),
'manager_required' => __('At least one user must keep the Manage user rights permission.', 'support-provisioning-portal'),
];
if (!isset($messages[$notice])) {
return;
}
$class = in_array($notice, ['manager_required', 'template_error'], true) ? 'notice notice-error' : 'notice notice-success';
printf(
'<div class="%s"><p>%s</p></div>',
esc_attr($class),
esc_html($messages[$notice])
);
}
private function render_admin_only_notice(): void
{
?>
<div class="spp-panel spp-admin-notice">
<p><?php echo esc_html__('Portal access is not enabled for your user. Administrative sections available to you are shown on this page.', 'support-provisioning-portal'); ?></p>
</div>
<?php
}
@@ -88,6 +134,7 @@ final class SPP_Admin_Page
class="spp-portal"
data-rest-url="<?php echo esc_url_raw(rest_url('support-provisioning/v1')); ?>"
data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"
data-permissions="<?php echo esc_attr((string) wp_json_encode($this->permissions->current_user_permissions())); ?>"
></div>
<?php
}
@@ -95,9 +142,10 @@ final class SPP_Admin_Page
private function render_settings(): void
{
?>
<form class="spp-settings" method="post" action="options.php">
<form class="spp-settings" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_settings">
<?php wp_nonce_field('spp_save_settings'); ?>
<h2><?php echo esc_html__('Proxmox Settings', 'support-provisioning-portal'); ?></h2>
<?php settings_fields('spp_settings'); ?>
<label>
<span>Mode</span>
<select name="spp_proxmox_mode">
@@ -115,7 +163,7 @@ final class SPP_Admin_Page
</label>
<label>
<span>Token Secret</span>
<input name="spp_proxmox_token_secret" type="password" value="<?php echo esc_attr(get_option('spp_proxmox_token_secret', '')); ?>">
<input name="spp_proxmox_token_secret" type="password" value="" placeholder="Leave blank to keep existing secret">
</label>
<label>
<span>Node</span>
@@ -136,51 +184,515 @@ final class SPP_Admin_Page
<?php
}
public function render_user_quota_field(WP_User $user): void
private function render_template_management(): void
{
if (!current_user_can('edit_users')) {
return;
}
$approved_templates = $this->repository->admin_templates();
$active_proxmox_ids = $this->repository->active_proxmox_template_ids();
$proxmox_error = null;
$quota = get_user_meta($user->ID, 'spp_memory_quota_mb', true);
try {
$proxmox_templates = $this->proxmox->list_templates();
} catch (Throwable $error) {
$proxmox_templates = [];
$proxmox_error = $error->getMessage();
}
?>
<h2><?php echo esc_html__('Support Provisioning Contingent', 'support-provisioning-portal'); ?></h2>
<table class="form-table" role="presentation">
<tr>
<th><label for="spp_memory_quota_mb">RAM limit override (MB)</label></th>
<td>
<input
id="spp_memory_quota_mb"
name="spp_memory_quota_mb"
type="number"
min="0"
step="256"
value="<?php echo esc_attr((string) $quota); ?>"
class="regular-text"
>
<p class="description"><?php echo esc_html__('Leave empty to use the default per-user limit. Set 0 for unlimited.', 'support-provisioning-portal'); ?></p>
</td>
</tr>
</table>
<section class="spp-settings spp-template-admin">
<div class="spp-section-header">
<div>
<h2><?php echo esc_html__('Templates', 'support-provisioning-portal'); ?></h2>
<p class="description"><?php echo esc_html__('Approved templates are stored by the plugin and used for new deployments. Proxmox templates can be imported from the configured node.', 'support-provisioning-portal'); ?></p>
</div>
</div>
<h3><?php echo esc_html__('Approved Templates', 'support-provisioning-portal'); ?></h3>
<div class="spp-template-list">
<?php if (empty($approved_templates)) : ?>
<div class="spp-panel spp-admin-notice">
<p><?php echo esc_html__('No plugin templates are approved yet. Import one from Proxmox below.', 'support-provisioning-portal'); ?></p>
</div>
<?php endif; ?>
<?php foreach ($approved_templates as $template) : ?>
<form class="spp-template-row" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_id" value="<?php echo esc_attr((string) $template['id']); ?>">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-row-head">
<div>
<strong><?php echo esc_html((string) $template['name']); ?></strong>
<span><?php echo esc_html(sprintf('PVE VMID %d', (int) $template['proxmoxTemplateId'])); ?></span>
</div>
<?php if (!empty($template['isActive'])) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Active', 'support-provisioning-portal'); ?></span>
<?php else : ?>
<span class="spp-badge STOPPED"><?php echo esc_html__('Inactive', 'support-provisioning-portal'); ?></span>
<?php endif; ?>
</div>
<div class="spp-template-fields">
<label>Name
<input name="spp_template_name" type="text" required maxlength="160" value="<?php echo esc_attr((string) $template['name']); ?>">
</label>
<label>PVE template VMID
<input name="spp_proxmox_template_id" type="number" min="1" required value="<?php echo esc_attr((string) $template['proxmoxTemplateId']); ?>">
</label>
<label>OS type
<?php $this->render_os_type_select((string) $template['osType']); ?>
</label>
<label>CPU cores
<input name="spp_cpu_cores" type="number" min="1" required value="<?php echo esc_attr((string) $template['cpuCores']); ?>">
</label>
<label>Memory MB
<input name="spp_memory_mb" type="number" min="128" step="128" required value="<?php echo esc_attr((string) $template['memoryMb']); ?>">
</label>
<label>Disk GB
<input name="spp_disk_gb" type="number" min="1" required value="<?php echo esc_attr((string) $template['diskGb']); ?>">
</label>
<label>Default TTL hours
<input name="spp_default_ttl_hours" type="number" min="1" max="720" required value="<?php echo esc_attr((string) $template['defaultTtlHours']); ?>">
</label>
<label class="spp-check">
<input name="spp_is_active" type="checkbox" value="1" <?php checked(!empty($template['isActive'])); ?>>
<span>Active for new deployments</span>
</label>
<label class="spp-template-description">Description
<textarea name="spp_template_description" rows="2" required><?php echo esc_textarea((string) $template['description']); ?></textarea>
</label>
</div>
<div class="spp-template-actions">
<button class="button button-primary" name="spp_template_action" value="save" type="submit"><?php echo esc_html__('Save Template', 'support-provisioning-portal'); ?></button>
<button class="button" name="spp_template_action" value="remove" type="submit"><?php echo esc_html__('Remove', 'support-provisioning-portal'); ?></button>
</div>
</form>
<?php endforeach; ?>
</div>
<h3><?php echo esc_html__('Proxmox Templates On Configured Node', 'support-provisioning-portal'); ?></h3>
<?php if ($proxmox_error !== null) : ?>
<p class="spp-error"><?php echo esc_html($proxmox_error); ?></p>
<?php elseif (empty($proxmox_templates)) : ?>
<div class="spp-panel spp-admin-notice">
<p><?php echo esc_html__('No QEMU templates were returned by Proxmox for the configured node.', 'support-provisioning-portal'); ?></p>
</div>
<?php else : ?>
<div class="spp-pve-template-grid">
<?php foreach ($proxmox_templates as $template) : ?>
<?php $is_imported = in_array((int) $template['vmId'], $active_proxmox_ids, true); ?>
<form class="spp-pve-template" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import">
<input type="hidden" name="spp_template_name" value="<?php echo esc_attr((string) $template['name']); ?>">
<input type="hidden" name="spp_proxmox_template_id" value="<?php echo esc_attr((string) $template['vmId']); ?>">
<input type="hidden" name="spp_cpu_cores" value="<?php echo esc_attr((string) $template['cpuCores']); ?>">
<input type="hidden" name="spp_memory_mb" value="<?php echo esc_attr((string) $template['memoryMb']); ?>">
<input type="hidden" name="spp_disk_gb" value="<?php echo esc_attr((string) $template['diskGb']); ?>">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-row-head">
<div>
<strong><?php echo esc_html((string) $template['name']); ?></strong>
<span><?php echo esc_html(sprintf('PVE VMID %d', (int) $template['vmId'])); ?></span>
</div>
<?php if ($is_imported) : ?>
<span class="spp-badge RUNNING"><?php echo esc_html__('Imported', 'support-provisioning-portal'); ?></span>
<?php endif; ?>
</div>
<div class="spp-meta">
<span>CPU<strong><?php echo esc_html((string) $template['cpuCores']); ?> cores</strong></span>
<span>Memory<strong><?php echo esc_html((string) $template['memoryMb']); ?> MB</strong></span>
<span>Disk<strong><?php echo esc_html((string) $template['diskGb']); ?> GB</strong></span>
<span>Status<strong><?php echo esc_html((string) $template['status']); ?></strong></span>
</div>
<?php if (!$is_imported) : ?>
<label>OS type
<?php $this->render_os_type_select('LINUX'); ?>
</label>
<label>Default TTL hours
<input name="spp_default_ttl_hours" type="number" min="1" max="720" value="168" required>
</label>
<label>Description
<textarea name="spp_template_description" rows="2" required><?php echo esc_textarea(sprintf('Imported from Proxmox template VMID %d.', (int) $template['vmId'])); ?></textarea>
</label>
<button class="button button-primary" type="submit"><?php echo esc_html__('Import Template', 'support-provisioning-portal'); ?></button>
<?php endif; ?>
</form>
<?php endforeach; ?>
</div>
<?php endif; ?>
<h3><?php echo esc_html__('Add Template Manually', 'support-provisioning-portal'); ?></h3>
<form class="spp-template-row" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_template">
<input type="hidden" name="spp_template_action" value="import">
<?php wp_nonce_field('spp_save_template'); ?>
<div class="spp-template-fields">
<label>Name
<input name="spp_template_name" type="text" required maxlength="160">
</label>
<label>PVE template VMID
<input name="spp_proxmox_template_id" type="number" min="1" required>
</label>
<label>OS type
<?php $this->render_os_type_select('LINUX'); ?>
</label>
<label>CPU cores
<input name="spp_cpu_cores" type="number" min="1" value="2" required>
</label>
<label>Memory MB
<input name="spp_memory_mb" type="number" min="128" step="128" value="2048" required>
</label>
<label>Disk GB
<input name="spp_disk_gb" type="number" min="1" value="32" required>
</label>
<label>Default TTL hours
<input name="spp_default_ttl_hours" type="number" min="1" max="720" value="168" required>
</label>
<label class="spp-template-description">Description
<textarea name="spp_template_description" rows="2" required></textarea>
</label>
</div>
<div class="spp-template-actions">
<button class="button button-primary" type="submit"><?php echo esc_html__('Add Template', 'support-provisioning-portal'); ?></button>
</div>
</form>
</section>
<?php
}
public function save_user_quota_field(int $user_id): void
private function render_user_access_management(): void
{
if (!current_user_can('edit_user', $user_id)) {
return;
$search = isset($_GET['spp_user_search']) ? sanitize_text_field((string) wp_unslash($_GET['spp_user_search'])) : '';
$users = $this->users_for_access_table($search);
$definitions = SPP_Permissions::definitions();
?>
<section class="spp-settings spp-user-access">
<div class="spp-section-header">
<div>
<h2><?php echo esc_html__('User Rights', 'support-provisioning-portal'); ?></h2>
<p class="description"><?php echo esc_html__('SSO users appear here after their first WordPress sign-in.', 'support-provisioning-portal'); ?></p>
</div>
<form class="spp-user-search" method="get">
<input type="hidden" name="page" value="support-provisioning-portal">
<input name="spp_user_search" type="search" value="<?php echo esc_attr($search); ?>" placeholder="Search users">
<button class="button" type="submit"><?php echo esc_html__('Search', 'support-provisioning-portal'); ?></button>
</form>
</div>
<?php if (empty($this->permissions->user_ids_with_permission(SPP_Permissions::MANAGE_PERMISSIONS))) : ?>
<p class="spp-warning"><?php echo esc_html__('Assign Manage user rights to at least one user to finish permission bootstrap.', 'support-provisioning-portal'); ?></p>
<?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="spp_save_user_access">
<?php wp_nonce_field('spp_save_user_access'); ?>
<table class="widefat striped spp-user-access-table">
<thead>
<tr>
<th><?php echo esc_html__('User', 'support-provisioning-portal'); ?></th>
<th><?php echo esc_html__('RAM override', 'support-provisioning-portal'); ?></th>
<th><?php echo esc_html__('Rights', 'support-provisioning-portal'); ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($users)) : ?>
<tr>
<td colspan="3"><?php echo esc_html__('No users found.', 'support-provisioning-portal'); ?></td>
</tr>
<?php endif; ?>
<?php foreach ($users as $user) : ?>
<?php
$user_permissions = $this->permissions->for_user((int) $user->ID);
$quota = get_user_meta((int) $user->ID, 'spp_memory_quota_mb', true);
?>
<tr>
<td>
<input type="hidden" name="spp_user_ids[]" value="<?php echo esc_attr((string) $user->ID); ?>">
<strong><?php echo esc_html($user->display_name !== '' ? $user->display_name : $user->user_login); ?></strong>
<span class="spp-user-login"><?php echo esc_html($user->user_login); ?></span>
<span class="spp-user-login"><?php echo esc_html($user->user_email); ?></span>
</td>
<td>
<input
class="small-text"
name="spp_memory_quota_mb[<?php echo esc_attr((string) $user->ID); ?>]"
type="number"
min="0"
step="256"
value="<?php echo esc_attr((string) $quota); ?>"
placeholder="Default"
>
</td>
<td>
<div class="spp-permission-groups">
<?php foreach (SPP_Permissions::groups() as $group => $permissions) : ?>
<fieldset>
<legend><?php echo esc_html($group); ?></legend>
<?php foreach ($permissions as $permission) : ?>
<label>
<input
type="checkbox"
name="spp_permissions[<?php echo esc_attr((string) $user->ID); ?>][]"
value="<?php echo esc_attr($permission); ?>"
<?php checked(in_array($permission, $user_permissions, true)); ?>
>
<span><?php echo esc_html($definitions[$permission]); ?></span>
</label>
<?php endforeach; ?>
</fieldset>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php submit_button('Save User Rights'); ?>
</form>
</section>
<?php
}
public function save_settings(): void
{
if (!$this->permissions->current_user_has(SPP_Permissions::MANAGE_SETTINGS)) {
wp_die(esc_html__('You do not have permission to save these settings.', 'support-provisioning-portal'));
}
if (!array_key_exists('spp_memory_quota_mb', $_POST)) {
return;
check_admin_referer('spp_save_settings');
update_option('spp_proxmox_mode', $this->posted_string('spp_proxmox_mode') === 'http' ? 'http' : 'mock');
update_option('spp_proxmox_base_url', $this->sanitize_proxmox_base_url($this->posted_string('spp_proxmox_base_url')));
update_option('spp_proxmox_token_id', sanitize_text_field($this->posted_string('spp_proxmox_token_id')));
$token_secret = sanitize_text_field($this->posted_string('spp_proxmox_token_secret'));
if ($token_secret !== '') {
update_option('spp_proxmox_token_secret', $token_secret);
}
update_option('spp_proxmox_node', sanitize_text_field($this->posted_string('spp_proxmox_node')));
update_option('spp_quota_user_memory_mb', max(0, absint($this->posted_string('spp_quota_user_memory_mb'))));
update_option('spp_quota_global_memory_mb', max(0, absint($this->posted_string('spp_quota_global_memory_mb'))));
$this->redirect_to_admin_page('settings_saved');
}
public function save_template(): void
{
if (!$this->permissions->current_user_has(SPP_Permissions::MANAGE_TEMPLATES)) {
wp_die(esc_html__('You do not have permission to save templates.', 'support-provisioning-portal'));
}
$raw_value = sanitize_text_field(wp_unslash($_POST['spp_memory_quota_mb']));
if ($raw_value === '') {
delete_user_meta($user_id, 'spp_memory_quota_mb');
return;
check_admin_referer('spp_save_template');
$template_id = absint($this->posted_string('spp_template_id'));
$action = sanitize_key($this->posted_string('spp_template_action'));
if ($action === 'remove') {
if ($template_id < 1) {
$this->redirect_to_admin_page('template_error');
}
$this->repository->deactivate_template($template_id, get_current_user_id());
$this->redirect_to_admin_page('template_removed');
}
update_user_meta($user_id, 'spp_memory_quota_mb', max(0, absint($raw_value)));
$data = $this->posted_template_data();
if ($data === null) {
$this->redirect_to_admin_page('template_error');
}
if (!$this->proxmox_template_exists((int) $data['proxmox_template_id'])) {
$this->redirect_to_admin_page('template_error');
}
if ($template_id > 0) {
$this->repository->update_template($template_id, $data, get_current_user_id());
} else {
$this->repository->upsert_template($data, get_current_user_id());
}
$this->redirect_to_admin_page('template_saved');
}
public function save_user_access(): void
{
if (!$this->permissions->current_user_has(SPP_Permissions::MANAGE_PERMISSIONS)) {
wp_die(esc_html__('You do not have permission to save user rights.', 'support-provisioning-portal'));
}
check_admin_referer('spp_save_user_access');
$user_ids = $this->posted_user_ids();
$posted_permissions = isset($_POST['spp_permissions']) ? (array) wp_unslash($_POST['spp_permissions']) : [];
$posted_quotas = isset($_POST['spp_memory_quota_mb']) ? (array) wp_unslash($_POST['spp_memory_quota_mb']) : [];
if (!$this->save_keeps_permission_manager($user_ids, $posted_permissions)) {
$this->redirect_to_admin_page('manager_required');
}
foreach ($user_ids as $user_id) {
$permissions = isset($posted_permissions[$user_id]) && is_array($posted_permissions[$user_id])
? SPP_Permissions::sanitize_permissions($posted_permissions[$user_id])
: [];
$quota_raw = isset($posted_quotas[$user_id]) ? sanitize_text_field((string) $posted_quotas[$user_id]) : '';
$memory_quota_mb = $quota_raw === '' ? null : max(0, absint($quota_raw));
$this->repository->update_user_access($user_id, $permissions, $memory_quota_mb, get_current_user_id());
}
$this->redirect_to_admin_page('user_access_saved');
}
/**
* @return array<int, WP_User>
*/
private function users_for_access_table(string $search): array
{
$args = [
'fields' => 'all',
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 200,
];
if ($search !== '') {
$args['search'] = '*' . $search . '*';
$args['search_columns'] = ['user_login', 'user_email', 'user_nicename', 'display_name'];
}
$users = get_users($args);
return is_array($users) ? $users : [];
}
private function render_os_type_select(string $selected): void
{
$options = [
'LINUX' => 'Linux',
'WINDOWS' => 'Windows',
'APPLIANCE' => 'Appliance',
'OTHER' => 'Other',
];
?>
<select name="spp_os_type">
<?php foreach ($options as $value => $label) : ?>
<option value="<?php echo esc_attr($value); ?>" <?php selected($selected, $value); ?>><?php echo esc_html($label); ?></option>
<?php endforeach; ?>
</select>
<?php
}
private function posted_string(string $key): string
{
return isset($_POST[$key]) ? (string) wp_unslash($_POST[$key]) : '';
}
private function sanitize_proxmox_base_url(string $value): string
{
$url = esc_url_raw($value);
if ($url === '') {
return '';
}
return wp_parse_url($url, PHP_URL_SCHEME) === 'https' ? $url : '';
}
private function proxmox_template_exists(int $vm_id): bool
{
try {
foreach ($this->proxmox->list_templates() as $template) {
if ((int) $template['vmId'] === $vm_id) {
return true;
}
}
} catch (Throwable) {
return false;
}
return false;
}
/**
* @return array<string, mixed>|null
*/
private function posted_template_data(): ?array
{
$name = sanitize_text_field($this->posted_string('spp_template_name'));
$proxmox_template_id = absint($this->posted_string('spp_proxmox_template_id'));
$description = sanitize_textarea_field($this->posted_string('spp_template_description'));
if ($name === '' || $proxmox_template_id < 1 || $description === '') {
return null;
}
return [
'template_key' => 'pve-template-' . $proxmox_template_id . '-' . sanitize_title($name),
'name' => $name,
'description' => $description,
'os_type' => $this->posted_os_type(),
'cpu_cores' => max(1, absint($this->posted_string('spp_cpu_cores'))),
'memory_mb' => max(128, absint($this->posted_string('spp_memory_mb'))),
'disk_gb' => max(1, absint($this->posted_string('spp_disk_gb'))),
'default_ttl_hours' => max(1, min(720, absint($this->posted_string('spp_default_ttl_hours')))),
'proxmox_template_id' => $proxmox_template_id,
'is_active' => $this->posted_string('spp_is_active') === '1',
];
}
private function posted_os_type(): string
{
$os_type = strtoupper(sanitize_key($this->posted_string('spp_os_type')));
return in_array($os_type, ['LINUX', 'WINDOWS', 'APPLIANCE', 'OTHER'], true) ? $os_type : 'OTHER';
}
/**
* @return array<int, int>
*/
private function posted_user_ids(): array
{
$raw_user_ids = isset($_POST['spp_user_ids']) ? (array) wp_unslash($_POST['spp_user_ids']) : [];
$user_ids = [];
foreach ($raw_user_ids as $user_id) {
$user_id = absint($user_id);
if ($user_id > 0 && !in_array($user_id, $user_ids, true)) {
$user_ids[] = $user_id;
}
}
return $user_ids;
}
/**
* @param array<int, int> $user_ids
* @param array<mixed> $posted_permissions
*/
private function save_keeps_permission_manager(array $user_ids, array $posted_permissions): bool
{
$updated_user_ids = array_flip($user_ids);
foreach ($this->permissions->user_ids_with_permission(SPP_Permissions::MANAGE_PERMISSIONS) as $manager_id) {
if (!isset($updated_user_ids[$manager_id])) {
return true;
}
}
foreach ($user_ids as $user_id) {
$permissions = isset($posted_permissions[$user_id]) && is_array($posted_permissions[$user_id])
? SPP_Permissions::sanitize_permissions($posted_permissions[$user_id])
: [];
if (in_array(SPP_Permissions::MANAGE_PERMISSIONS, $permissions, true)) {
return true;
}
}
return false;
}
private function redirect_to_admin_page(string $notice): void
{
wp_safe_redirect(add_query_arg([
'page' => 'support-provisioning-portal',
'spp_notice' => $notice,
], admin_url('admin.php')));
exit;
}
}

View File

@@ -17,6 +17,35 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
$this->options = $options;
}
public function list_templates(): array
{
$data = $this->request("/nodes/{$this->options['node']}/qemu", 'GET');
$vms = is_array($data) ? $data : [];
$templates = [];
foreach ($vms as $vm) {
if (!is_array($vm) || empty($vm['template']) || empty($vm['vmid'])) {
continue;
}
$templates[] = [
'vmId' => (int) $vm['vmid'],
'name' => isset($vm['name']) && $vm['name'] !== '' ? (string) $vm['name'] : 'template-' . (int) $vm['vmid'],
'cpuCores' => max(1, (int) ($vm['cpus'] ?? 1)),
'memoryMb' => $this->bytes_to_mb((int) ($vm['maxmem'] ?? 0)),
'diskGb' => $this->bytes_to_gb((int) ($vm['maxdisk'] ?? 0)),
'status' => isset($vm['status']) ? (string) $vm['status'] : 'unknown',
];
}
usort(
$templates,
static fn(array $left, array $right): int => strcasecmp((string) $left['name'], (string) $right['name'])
);
return $templates;
}
public function clone_vm(array $input): array
{
$vm_id = (int) $this->request('/cluster/nextid', 'GET');
@@ -86,6 +115,24 @@ final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
return array_values(array_unique($ips));
}
private function bytes_to_mb(int $bytes): int
{
if ($bytes < 1) {
return 1024;
}
return max(128, (int) ceil($bytes / 1048576));
}
private function bytes_to_gb(int $bytes): int
{
if ($bytes < 1) {
return 8;
}
return max(1, (int) ceil($bytes / 1073741824));
}
/**
* @param array<string, int|string> $body
* @return mixed

View File

@@ -6,6 +6,36 @@ if (!defined('ABSPATH')) {
final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
{
public function list_templates(): array
{
return [
[
'vmId' => 9001,
'name' => 'Turnkey PBX Test Appliance',
'cpuCores' => 2,
'memoryMb' => 2048,
'diskGb' => 24,
'status' => 'stopped',
],
[
'vmId' => 9002,
'name' => 'Windows Support Client',
'cpuCores' => 4,
'memoryMb' => 8192,
'diskGb' => 80,
'status' => 'stopped',
],
[
'vmId' => 9003,
'name' => 'Linux Utility VM',
'cpuCores' => 2,
'memoryMb' => 2048,
'diskGb' => 32,
'status' => 'stopped',
],
];
}
public function clone_vm(array $input): array
{
$next_id = (int) get_option('spp_mock_next_vm_id', 10000);

View File

@@ -0,0 +1,181 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
final class SPP_Permissions
{
public const META_KEY = 'spp_permissions';
public const VIEW_PORTAL = 'view_portal';
public const CREATE_DEPLOYMENTS = 'create_deployments';
public const START_DEPLOYMENTS = 'start_deployments';
public const STOP_DEPLOYMENTS = 'stop_deployments';
public const PROLONG_DEPLOYMENTS = 'prolong_deployments';
public const REFRESH_DEPLOYMENT_IPS = 'refresh_deployment_ips';
public const DELETE_DEPLOYMENTS = 'delete_deployments';
public const MANAGE_ALL_DEPLOYMENTS = 'manage_all_deployments';
public const MANAGE_TEMPLATES = 'manage_templates';
public const MANAGE_SETTINGS = 'manage_settings';
public const MANAGE_PERMISSIONS = 'manage_permissions';
/**
* @return array<string, string>
*/
public static function definitions(): array
{
return [
self::VIEW_PORTAL => 'Open portal and view deployments',
self::CREATE_DEPLOYMENTS => 'Create deployments',
self::START_DEPLOYMENTS => 'Start deployments',
self::STOP_DEPLOYMENTS => 'Stop deployments',
self::PROLONG_DEPLOYMENTS => 'Prolong deployments',
self::REFRESH_DEPLOYMENT_IPS => 'Refresh IP addresses',
self::DELETE_DEPLOYMENTS => 'Delete deployments',
self::MANAGE_ALL_DEPLOYMENTS => 'View and manage all deployments',
self::MANAGE_TEMPLATES => 'Manage templates',
self::MANAGE_SETTINGS => 'Manage Proxmox settings and quotas',
self::MANAGE_PERMISSIONS => 'Manage user rights',
];
}
/**
* @return array<string, array<int, string>>
*/
public static function groups(): array
{
return [
'Portal' => [
self::VIEW_PORTAL,
self::CREATE_DEPLOYMENTS,
],
'Lifecycle' => [
self::START_DEPLOYMENTS,
self::STOP_DEPLOYMENTS,
self::PROLONG_DEPLOYMENTS,
self::REFRESH_DEPLOYMENT_IPS,
self::DELETE_DEPLOYMENTS,
],
'Administration' => [
self::MANAGE_ALL_DEPLOYMENTS,
self::MANAGE_TEMPLATES,
self::MANAGE_SETTINGS,
self::MANAGE_PERMISSIONS,
],
];
}
/**
* @param array<mixed> $permissions
* @return array<int, string>
*/
public static function sanitize_permissions(array $permissions): array
{
$valid = array_keys(self::definitions());
$selected = [];
foreach ($permissions as $permission) {
$permission = sanitize_key((string) $permission);
if (in_array($permission, $valid, true) && !in_array($permission, $selected, true)) {
$selected[] = $permission;
}
}
$portal_rights = array_diff($selected, [
self::VIEW_PORTAL,
self::MANAGE_TEMPLATES,
self::MANAGE_SETTINGS,
self::MANAGE_PERMISSIONS,
]);
if (!empty($portal_rights) && !in_array(self::VIEW_PORTAL, $selected, true)) {
$selected[] = self::VIEW_PORTAL;
}
return array_values(array_intersect($valid, $selected));
}
public function current_user_has(string $permission): bool
{
if (!array_key_exists($permission, self::definitions())) {
return false;
}
if ($this->user_has(get_current_user_id(), $permission)) {
return true;
}
return $this->has_bootstrap_access();
}
public function current_user_has_any(): bool
{
return !empty($this->current_user_permissions());
}
/**
* @return array<int, string>
*/
public function current_user_permissions(): array
{
if ($this->has_bootstrap_access()) {
return array_keys(self::definitions());
}
return $this->for_user(get_current_user_id());
}
public function user_has(int $user_id, string $permission): bool
{
if (!array_key_exists($permission, self::definitions())) {
return false;
}
return in_array($permission, $this->for_user($user_id), true);
}
/**
* @return array<int, string>
*/
public function for_user(int $user_id): array
{
if ($user_id < 1) {
return [];
}
$permissions = get_user_meta($user_id, self::META_KEY, true);
return is_array($permissions) ? self::sanitize_permissions($permissions) : [];
}
/**
* @return array<int, int>
*/
public function user_ids_with_permission(string $permission): array
{
if (!array_key_exists($permission, self::definitions())) {
return [];
}
global $wpdb;
$like = '%"' . $wpdb->esc_like($permission) . '"%';
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value LIKE %s",
self::META_KEY,
$like
)
);
return array_values(array_map('intval', is_array($ids) ? $ids : []));
}
private function has_bootstrap_access(): bool
{
return is_user_logged_in()
&& current_user_can('manage_options')
&& empty($this->user_ids_with_permission(self::MANAGE_PERMISSIONS));
}
}

View File

@@ -22,14 +22,15 @@ final class SPP_Plugin
SPP_Activator::maybe_upgrade();
$repository = new SPP_Repository();
$permissions = new SPP_Permissions();
$proxmox = $this->make_proxmox_client();
$expiration_service = new SPP_Expiration_Service($repository, $proxmox);
add_action('spp_expire_deployments', [$expiration_service, 'expire_due_deployments']);
(new SPP_REST_Controller($repository, $proxmox, $expiration_service))->register_hooks();
(new SPP_Admin_Page())->register_hooks();
(new SPP_Shortcode())->register_hooks();
(new SPP_REST_Controller($repository, $proxmox, $expiration_service, $permissions))->register_hooks();
(new SPP_Admin_Page($repository, $permissions, $proxmox))->register_hooks();
(new SPP_Shortcode($permissions))->register_hooks();
}
private function make_proxmox_client(): SPP_Proxmox_Client

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'],

View File

@@ -11,7 +11,8 @@ final class SPP_REST_Controller
public function __construct(
private SPP_Repository $repository,
private SPP_Proxmox_Client $proxmox,
private SPP_Expiration_Service $expiration_service
private SPP_Expiration_Service $expiration_service,
private SPP_Permissions $permissions
) {
}
@@ -43,7 +44,7 @@ final class SPP_REST_Controller
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_create_deployments'],
],
]);
@@ -56,43 +57,97 @@ final class SPP_REST_Controller
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'delete_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_delete_deployments'],
],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\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<id>\d+)/shares/(?P<user_id>\d+)', [
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'unshare_deployment'],
'permission_callback' => [$this, 'can_read'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/start', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'start_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_start_deployments'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/stop', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'stop_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_stop_deployments'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/prolong', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'prolong_deployment'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_prolong_deployments'],
]);
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/refresh-ips', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'refresh_deployment_ips'],
'permission_callback' => [$this, 'can_mutate'],
'permission_callback' => [$this, 'can_refresh_deployment_ips'],
]);
}
public function can_read(): bool
{
return is_user_logged_in() && current_user_can('read');
return is_user_logged_in() && $this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL);
}
public function can_mutate(): bool
public function can_create_deployments(): bool
{
return is_user_logged_in() && current_user_can('edit_posts');
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
@@ -113,19 +168,26 @@ final class SPP_REST_Controller
{
$this->sync_expirations();
return rest_ensure_response($this->repository->deployments());
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((int) $request['id']);
$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($deployment);
return rest_ensure_response($this->deployment_response($deployment));
}
public function create_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
@@ -182,7 +244,13 @@ final class SPP_REST_Controller
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
}
return new WP_REST_Response($deployment, 201);
$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
@@ -233,9 +301,13 @@ final class SPP_REST_Controller
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($id);
$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]);
@@ -252,13 +324,16 @@ final class SPP_REST_Controller
}
if ($record['status'] === 'EXPIRED') {
$quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], get_current_user_id());
$quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], (int) $record['requested_by']);
if ($quota_error instanceof WP_Error) {
return $quota_error;
}
}
return rest_ensure_response($this->repository->prolong_deployment($id, $ttl_hours, get_current_user_id()));
$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
@@ -271,12 +346,16 @@ final class SPP_REST_Controller
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 VM id.', ['status' => 409]);
}
try {
$deployment = $this->repository->update_deployment_ips(
$this->repository->update_deployment_ips(
$id,
$this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']),
get_current_user_id()
@@ -285,7 +364,69 @@ final class SPP_REST_Controller
return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
}
return rest_ensure_response($deployment);
$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
@@ -297,6 +438,14 @@ final class SPP_REST_Controller
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_vm' && !$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_vm' && $record['status'] === 'EXPIRED') {
return new WP_Error(
'spp_expired_deployment',
@@ -312,7 +461,7 @@ final class SPP_REST_Controller
try {
$this->proxmox->{$method}((int) $record['proxmox_vm_id']);
if ($method === 'start_vm') {
$deployment = $this->repository->update_deployment_status_and_ips(
$this->repository->update_deployment_status_and_ips(
$id,
$status,
$this->safe_ip_addresses((int) $record['proxmox_vm_id']),
@@ -320,13 +469,80 @@ final class SPP_REST_Controller
get_current_user_id()
);
} else {
$deployment = $this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_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]);
}
return rest_ensure_response($deployment);
if ($method === 'delete_vm') {
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<string, mixed>|null $deployment
* @return array<string, mixed>
*/
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

View File

@@ -6,6 +6,10 @@ if (!defined('ABSPATH')) {
final class SPP_Shortcode
{
public function __construct(private SPP_Permissions $permissions)
{
}
public function register_hooks(): void
{
add_shortcode('support_provisioning_portal', [$this, 'render']);
@@ -20,13 +24,18 @@ final class SPP_Shortcode
return '<p class="spp-login-required">Please sign in to access the provisioning portal.</p>';
}
if (!$this->permissions->current_user_has(SPP_Permissions::VIEW_PORTAL)) {
return '<p class="spp-login-required">You do not have permission to access the provisioning portal.</p>';
}
wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION);
wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true);
return sprintf(
'<div id="spp-portal-root" class="spp-portal" data-rest-url="%s" data-nonce="%s"></div>',
'<div id="spp-portal-root" class="spp-portal" data-rest-url="%s" data-nonce="%s" data-permissions="%s"></div>',
esc_url_raw(rest_url('support-provisioning/v1')),
esc_attr(wp_create_nonce('wp_rest'))
esc_attr(wp_create_nonce('wp_rest')),
esc_attr((string) wp_json_encode($this->permissions->current_user_permissions()))
);
}
}

View File

@@ -6,6 +6,11 @@ if (!defined('ABSPATH')) {
interface SPP_Proxmox_Client
{
/**
* @return array<int, array{vmId:int,name:string,cpuCores:int,memoryMb:int,diskGb:int,status:string}>
*/
public function list_templates(): array;
/**
* @param array<string, int|string> $input
* @return array{vm_id:int}

View File

@@ -2,7 +2,7 @@
/**
* Plugin Name: Support Provisioning Portal
* Description: Internal self-service portal for provisioning standardized Proxmox VE VMs.
* Version: 0.3.0
* Version: 0.6.0
* Author: Internal Support
* Requires PHP: 8.0
* Requires at least: 6.2
@@ -13,11 +13,12 @@ if (!defined('ABSPATH')) {
exit;
}
define('SPP_VERSION', '0.3.0');
define('SPP_VERSION', '0.6.0');
define('SPP_PLUGIN_FILE', __FILE__);
define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__));
require_once SPP_PLUGIN_DIR . 'includes/class-spp-permissions.php';
require_once SPP_PLUGIN_DIR . 'includes/class-spp-activator.php';
require_once SPP_PLUGIN_DIR . 'includes/class-spp-repository.php';
require_once SPP_PLUGIN_DIR . 'includes/interface-spp-proxmox-client.php';