From 90a97c4afb61a179eda40e23b97637dd90507b55 Mon Sep 17 00:00:00 2001 From: Prophet731 Date: Thu, 19 Mar 2026 05:40:32 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20major=20enhancement=20=E2=80=94=20OS=20?= =?UTF-8?q?gallery,=20server=20rename,=20traffic=20chart,=20backups,=20VNC?= =?UTF-8?q?=20toggle,=20password=20reset,=20Redis=20caching,=20UX=20improv?= =?UTF-8?q?ements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove client IP removal capability (keep backend methods removed too) - Add copy-to-clipboard buttons for IP addresses with tooltip feedback - Replace OS dropdown with tile gallery (grouped, searchable, brand colors, EOL badges) in rebuild panel and checkout page - Add inline server rename with friendly name generator and RFC 1123 validation - Add traffic statistics canvas chart with responsive resize in resources panel - Add backup listing timeline in manage panel with show-all expansion - Add VNC enable/disable toggle with connection details and password copy - Add server root password reset with auto-clipboard copy (never displayed) - Add skeleton loading placeholders, action cooldowns (power 3s, rebuild 30s), progress indicator with elapsed timer - Sanitize all client-facing error messages (no raw API errors exposed) - Convert all state-mutating AJAX calls from GET to POST - Add explicit break after all output() calls in client.php - Add Redis-backed API response caching (Cache.php): OS templates 10min, traffic/backups 2min, currencies 30min, packages 10min - Add GitHub Actions workflow for weekly VirtFusion API change detection - Move cache busting step after semantic-release in publish workflow - Add endpoint doc generator script and OpenAPI baseline placeholder - Improve hostname generation entropy (bin2hex random_bytes) - Add .superpowers/ to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/api-sync-check.yml | 65 ++ .github/workflows/publish-release.yml | 11 + .gitignore | 3 +- docs/openapi-baseline.yaml | 7 + modules/servers/VirtFusionDirect/client.php | 131 +++- modules/servers/VirtFusionDirect/hooks.php | 194 +++++- .../servers/VirtFusionDirect/lib/Cache.php | 131 ++++ .../VirtFusionDirect/lib/ConfigureService.php | 21 +- .../servers/VirtFusionDirect/lib/Module.php | 312 +++++---- .../VirtFusionDirect/templates/css/module.css | 256 +++++++- .../VirtFusionDirect/templates/js/module.js | 607 ++++++++++++++++-- .../VirtFusionDirect/templates/overview.tpl | 125 +++- scripts/generate-endpoint-doc.sh | 33 + 13 files changed, 1647 insertions(+), 249 deletions(-) create mode 100644 .github/workflows/api-sync-check.yml create mode 100644 docs/openapi-baseline.yaml create mode 100644 modules/servers/VirtFusionDirect/lib/Cache.php create mode 100755 scripts/generate-endpoint-doc.sh diff --git a/.github/workflows/api-sync-check.yml b/.github/workflows/api-sync-check.yml new file mode 100644 index 0000000..cd0a851 --- /dev/null +++ b/.github/workflows/api-sync-check.yml @@ -0,0 +1,65 @@ +name: VirtFusion API Change Detection + +on: + schedule: + - cron: '0 9 * * 1' # Monday 9am UTC + workflow_dispatch: + +jobs: + check-api: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Download current API spec + run: curl -sSL -o /tmp/openapi-current.yaml https://docs.virtfusion.com/api/openapi.yaml + + - name: Compare with baseline + id: diff + run: | + if [ ! -f docs/openapi-baseline.yaml ]; then + echo "No baseline found — creating initial baseline" + cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml + echo "changed=initial" >> "$GITHUB_OUTPUT" + elif ! diff -q docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /dev/null 2>&1; then + echo "API spec has changed" + diff docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /tmp/api-diff.txt || true + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "No changes detected" + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create issue on change + if: steps.diff.outputs.changed == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const diff = fs.readFileSync('/tmp/api-diff.txt', 'utf8').substring(0, 60000); + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `VirtFusion API spec changed (${new Date().toISOString().split('T')[0]})`, + body: `The VirtFusion OpenAPI spec has been updated.\n\n
Diff\n\n\`\`\`diff\n${diff}\n\`\`\`\n
\n\nReview the changes and update the module if needed.`, + labels: ['api-sync'] + }); + + - name: Update baseline and create PR + if: steps.diff.outputs.changed == 'true' || steps.diff.outputs.changed == 'initial' + run: | + cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + BRANCH="api-sync/$(date +%Y-%m-%d)" + git checkout -b "$BRANCH" + git add docs/openapi-baseline.yaml + git commit -m "chore: update VirtFusion API baseline spec" + git push origin "$BRANCH" + gh pr create --title "chore: update VirtFusion API baseline" --body "Automated update of the VirtFusion OpenAPI baseline spec." --base main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index df0e616..51edfb1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -33,6 +33,17 @@ jobs: # GITHUB_TOKEN is required for authentication GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Generate cache busting version hashes + run: | + CSS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/css/module.css | cut -c1-8) + JS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/js/module.js | cut -c1-8) + echo "{\"css\":\"$CSS_HASH\",\"js\":\"$JS_HASH\"}" > modules/servers/VirtFusionDirect/templates/version.json + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add modules/servers/VirtFusionDirect/templates/version.json + git diff --cached --quiet || git commit -m "chore: update asset version hashes [skip ci]" + git push || true + # To make this work, you must follow the Conventional Commits specification. # Examples: # - fix: correct a typo in the documentation diff --git a/.gitignore b/.gitignore index 57f1cb2..1320326 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/.idea/ \ No newline at end of file +/.idea/ +/.superpowers/ \ No newline at end of file diff --git a/docs/openapi-baseline.yaml b/docs/openapi-baseline.yaml new file mode 100644 index 0000000..de4aaf3 --- /dev/null +++ b/docs/openapi-baseline.yaml @@ -0,0 +1,7 @@ +# VirtFusion OpenAPI Baseline +# This file will be auto-populated by the api-sync-check workflow +# on first run. Do not edit manually. +openapi: "3.0.0" +info: + title: VirtFusion API Baseline Placeholder + version: "0.0.0" diff --git a/modules/servers/VirtFusionDirect/client.php b/modules/servers/VirtFusionDirect/client.php index c081b53..224aeff 100644 --- a/modules/servers/VirtFusionDirect/client.php +++ b/modules/servers/VirtFusionDirect/client.php @@ -23,12 +23,14 @@ switch ($action) { if (!$client) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $data = $vf->resetUserPassword($serviceID, $client); if ($data) { $vf->output(['success' => true, 'data' => $data->data], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500); @@ -43,6 +45,7 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $data = $vf->fetchServerData($serviceID); @@ -50,6 +53,7 @@ switch ($action) { if ($data) { (new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data); $vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500); @@ -64,12 +68,14 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $token = $vf->fetchLoginTokens($serviceID); if ($token) { $vf->output(['success' => true, 'token_url' => $token], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500); @@ -84,19 +90,22 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } - $powerAction = isset($_GET['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_GET['powerAction']) : ''; + $powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : ''; $allowedActions = ['boot', 'shutdown', 'restart', 'poweroff']; if (!in_array($powerAction, $allowedActions, true)) { $vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400); + break; } $result = $vf->serverPowerAction($serviceID, $powerAction); if ($result) { $vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500); @@ -111,19 +120,22 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } - $osId = isset($_GET['osId']) ? (int) $_GET['osId'] : 0; - $hostname = isset($_GET['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_GET['hostname']) : null; + $osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0; + $hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null; if ($osId <= 0) { $vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400); + break; } $result = $vf->rebuildServer($serviceID, $osId, $hostname); if ($result) { $vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Server rebuild failed. The server may be locked or unavailable.'], true, true, 500); @@ -138,19 +150,21 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } - $newName = isset($_GET['name']) ? trim($_GET['name']) : ''; - $newName = htmlspecialchars($newName, ENT_QUOTES, 'UTF-8'); + $newName = isset($_POST['name']) ? trim($_POST['name']) : ''; - if (empty($newName) || strlen($newName) > 255) { + if (empty($newName) || strlen($newName) > 63 || !preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) { $vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400); + break; } $result = $vf->renameServer($serviceID, $newName); if ($result) { $vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500); @@ -165,44 +179,95 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $templates = $vf->fetchOsTemplates($serviceID); if ($templates !== false) { $vf->output(['success' => true, 'data' => $templates], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500); break; // ================================================================= - // IP Address Management + // Server Password Reset // ================================================================= /** - * Remove an IPv4 address. + * Reset server root password. */ - case 'removeIPv4': + case 'resetServerPassword': $serviceID = $vf->validateServiceID(true); if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } - $ipAddress = isset($_GET['ip']) ? trim($_GET['ip']) : ''; - if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - $vf->output(['success' => false, 'errors' => 'Invalid IPv4 address'], true, true, 400); + $result = $vf->resetServerPassword($serviceID); + + if ($result !== false) { + $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; } - $result = $vf->removeIPv4($serviceID, $ipAddress); + $vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500); + break; - if ($result) { - $vf->output(['success' => true, 'data' => ['message' => 'IPv4 address removed successfully']], true, true, 200); + // ================================================================= + // Backup Listing + // ================================================================= + + /** + * Get server backups. + */ + case 'backups': + + $serviceID = $vf->validateServiceID(true); + + if (!$vf->validateUserOwnsService($serviceID)) { + $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } - $vf->output(['success' => false, 'errors' => 'Failed to remove IPv4 address'], true, true, 500); + $result = $vf->getServerBackups($serviceID); + + if ($result !== false) { + $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; + } + + $vf->output(['success' => false, 'errors' => 'Unable to retrieve backups'], true, true, 500); + break; + + // ================================================================= + // Traffic Statistics + // ================================================================= + + /** + * Get traffic statistics for a server. + */ + case 'trafficStats': + + $serviceID = $vf->validateServiceID(true); + + if (!$vf->validateUserOwnsService($serviceID)) { + $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; + } + + $result = $vf->getTrafficStats($serviceID); + + if ($result !== false) { + $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; + } + + $vf->output(['success' => false, 'errors' => 'Unable to retrieve traffic statistics'], true, true, 500); break; // ================================================================= @@ -218,17 +283,42 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $result = $vf->getVncConsole($serviceID); if ($result !== false) { $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500); break; + /** + * Toggle VNC on/off. + */ + case 'toggleVnc': + + $serviceID = $vf->validateServiceID(true); + + if (!$vf->validateUserOwnsService($serviceID)) { + $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; + } + + $enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1'; + $result = $vf->toggleVnc($serviceID, $enabled); + + if ($result !== false) { + $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; + } + + $vf->output(['success' => false, 'errors' => 'Failed to toggle VNC'], true, true, 500); + break; + // ================================================================= // Self Service — Credit & Usage // ================================================================= @@ -242,12 +332,14 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $result = $vf->getSelfServiceUsage($serviceID); if ($result !== false) { $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500); @@ -262,12 +354,14 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } $result = $vf->getSelfServiceReport($serviceID); if ($result !== false) { $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500); @@ -282,17 +376,20 @@ switch ($action) { if (!$vf->validateUserOwnsService($serviceID)) { $vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403); + break; } - $tokens = isset($_GET['tokens']) ? (float) $_GET['tokens'] : 0; + $tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0; if ($tokens <= 0) { $vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400); + break; } $result = $vf->addSelfServiceCredit($serviceID, $tokens); if ($result !== false) { $vf->output(['success' => true, 'data' => $result], true, true, 200); + break; } $vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500); diff --git a/modules/servers/VirtFusionDirect/hooks.php b/modules/servers/VirtFusionDirect/hooks.php index 70be304..5faf246 100644 --- a/modules/servers/VirtFusionDirect/hooks.php +++ b/modules/servers/VirtFusionDirect/hooks.php @@ -86,19 +86,46 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) { return null; } - $dropdownOptions = []; - - foreach ($templates_data['data'] as $osCategory) { - foreach ($osCategory['templates'] as $template) { - $optionValue = $template['id']; - $optionLabel = htmlspecialchars($template['name'] . " " . $template['version'] . " " . $template['variant'], ENT_QUOTES, 'UTF-8'); - $dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel]; - } + $baseUrl = ''; + $firstServer = \WHMCS\Database\Capsule::table('tblservers') + ->where('type', 'VirtFusionDirect') + ->where('disabled', 0) + ->first(); + if ($firstServer) { + $baseUrl = rtrim('https://' . $firstServer->hostname, '/'); } - usort($dropdownOptions, function ($a, $b) { - return strcmp($a['name'], $b['name']); - }); + $categories = []; + $otherTemplates = []; + + foreach ($templates_data['data'] as $osCategory) { + $catTemplates = []; + foreach ($osCategory['templates'] as $template) { + $catTemplates[] = [ + 'id' => $template['id'], + 'name' => htmlspecialchars($template['name'], ENT_QUOTES, 'UTF-8'), + 'version' => htmlspecialchars($template['version'] ?? '', ENT_QUOTES, 'UTF-8'), + 'variant' => htmlspecialchars($template['variant'] ?? '', ENT_QUOTES, 'UTF-8'), + 'icon' => $template['icon'] ?? null, + 'eol' => $template['eol'] ?? false, + 'description' => htmlspecialchars($template['description'] ?? '', ENT_QUOTES, 'UTF-8'), + ]; + } + if (count($catTemplates) <= 1) { + $otherTemplates = array_merge($otherTemplates, $catTemplates); + } else { + $categories[] = [ + 'name' => htmlspecialchars($osCategory['name'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'), + 'icon' => $osCategory['icon'] ?? null, + 'templates' => $catTemplates, + ]; + } + } + if (!empty($otherTemplates)) { + $categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates]; + } + + $galleryData = ['baseUrl' => $baseUrl, 'categories' => $categories]; $sshKeys = []; $sshKeysOptions = []; @@ -139,10 +166,11 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) { $systemUrl = Database::getSystemUrl(); return " + + + {if $serviceStatus eq 'Active'} @@ -12,9 +12,27 @@
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -27,7 +45,15 @@
Name:
-
+
+
+ + + +
+ + +
Hostname:
@@ -136,6 +162,27 @@
{/if} +
+
+ +

Reset the server's root password. The new password will be copied to your clipboard automatically.

+ +
+ +
@@ -150,16 +197,16 @@
Warning: Rebuilding your server will erase all data on the server and reinstall the operating system. This action cannot be undone.
-
-
-
- - -
-
+ +
+ +
+ + +
+ + @@ -246,10 +308,37 @@

Access your server's console directly in your browser. The server must be running for VNC access.

- +
+ + +
+
diff --git a/scripts/generate-endpoint-doc.sh b/scripts/generate-endpoint-doc.sh new file mode 100755 index 0000000..a57ae22 --- /dev/null +++ b/scripts/generate-endpoint-doc.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Generate API endpoint documentation from PHP source files +# Usage: bash scripts/generate-endpoint-doc.sh > docs/API-ENDPOINTS.md + +MODULE_DIR="modules/servers/VirtFusionDirect" + +echo "# VirtFusion WHMCS Module — API Endpoints" +echo "" +echo "Auto-generated from source code. Do not edit manually." +echo "" +echo "| Endpoint Pattern | HTTP Method | PHP File | Function |" +echo "|---|---|---|---|" + +# Extract API URL patterns from PHP files +grep -rn "->get\|->post\|->put\|->patch\|->delete" "$MODULE_DIR/lib/" 2>/dev/null | \ + grep -oP "(?<=>)(get|post|put|patch|delete)\(.*?'[^']*'" | \ + while IFS= read -r line; do + method=$(echo "$line" | grep -oP "^(get|post|put|patch|delete)" | tr '[:lower:]' '[:upper:]') + url=$(echo "$line" | grep -oP "'[^']*'" | tr -d "'") + echo "| \`$url\` | $method | - | - |" + done + +echo "" +echo "## Client Endpoints (client.php)" +echo "" +echo "| Action | Description |" +echo "|---|---|" + +grep -n "case '" "$MODULE_DIR/client.php" 2>/dev/null | \ + while IFS= read -r line; do + action=$(echo "$line" | grep -oP "case '\K[^']+") + echo "| \`$action\` | - |" + done