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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
181
support-provisioning-portal/includes/class-spp-permissions.php
Normal file
181
support-provisioning-portal/includes/class-spp-permissions.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user