feat: major enhancement — OS gallery, server rename, traffic chart, backups, VNC toggle, password reset, Redis caching, UX improvements

- 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) <noreply@anthropic.com>
This commit is contained in:
Prophet731
2026-03-19 05:40:32 -05:00
parent 538974e0fe
commit 90a97c4afb
13 changed files with 1647 additions and 249 deletions

View File

@@ -0,0 +1,131 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect;
class Cache
{
const PREFIX = 'vfd:';
/** @var \Redis|null */
private static $redis = null;
/** @var bool */
private static $available = true;
/**
* Get a Redis connection, or null if unavailable.
*
* @return \Redis|null
*/
private static function redis()
{
if (!self::$available) {
return null;
}
if (self::$redis !== null) {
return self::$redis;
}
if (!class_exists('Redis')) {
self::$available = false;
return null;
}
try {
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379, 1.0);
self::$redis = $redis;
return $redis;
} catch (\Exception $e) {
self::$available = false;
return null;
}
}
/**
* Get a cached value.
*
* @param string $key
* @return mixed|null Returns null on miss
*/
public static function get($key)
{
$redis = self::redis();
if (!$redis) {
return null;
}
try {
$data = $redis->get(self::PREFIX . $key);
if ($data === false) {
return null;
}
return json_decode($data, true);
} catch (\Exception $e) {
return null;
}
}
/**
* Store a value in cache.
*
* @param string $key
* @param mixed $value
* @param int $ttl Time-to-live in seconds
*/
public static function set($key, $value, $ttl = 300)
{
$redis = self::redis();
if (!$redis) {
return;
}
try {
$redis->setex(self::PREFIX . $key, $ttl, json_encode($value));
} catch (\Exception $e) {
// Silently fail — caching is optional
}
}
/**
* Delete a cached value.
*
* @param string $key
*/
public static function forget($key)
{
$redis = self::redis();
if (!$redis) {
return;
}
try {
$redis->del(self::PREFIX . $key);
} catch (\Exception $e) {
// Silently fail
}
}
/**
* Delete all cache keys matching a pattern.
*
* @param string $pattern Glob pattern (e.g., "os:*")
*/
public static function forgetPattern($pattern)
{
$redis = self::redis();
if (!$redis) {
return;
}
try {
$keys = $redis->keys(self::PREFIX . $pattern);
if (!empty($keys)) {
$redis->del($keys);
}
} catch (\Exception $e) {
// Silently fail
}
}
}

View File

@@ -26,6 +26,12 @@ class ConfigureService extends Module
*/
public function fetchPackageId(string $packageName): ?int
{
$cacheKey = 'pkg_name:' . md5($packageName);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
@@ -38,6 +44,7 @@ class ConfigureService extends Module
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
Cache::set($cacheKey, $package['id'], 600);
return $package['id'];
}
}
@@ -72,6 +79,12 @@ class ConfigureService extends Module
return null;
}
$cacheKey = 'tpl:' . $serverPackageId;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
@@ -80,7 +93,9 @@ class ConfigureService extends Module
sprintf("%s/media/templates/fromServerPackageSpec/%d", $this->cp['url'], $serverPackageId)
);
return $this->decodeResponseFromJson($response);
$result = $this->decodeResponseFromJson($response);
Cache::set($cacheKey, $result, 600);
return $result;
}
/**
@@ -139,8 +154,8 @@ class ConfigureService extends Module
$request = $this->initCurl($this->cp['token']);
// Generate a random 8 character hostname
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
// Generate a hostname with sufficient entropy to avoid collisions
$hostname = 'vps-' . bin2hex(random_bytes(4));
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
$sshKeyId = null;

View File

@@ -225,6 +225,7 @@ class Module
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
Cache::forgetPattern('backups:' . (int) $service->server_id);
return json_decode($data) ?: (object) ['success' => true];
}
}
@@ -292,6 +293,12 @@ class Module
return false;
}
$cacheKey = 'os:' . (int) $product->configoption2;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/media/templates/fromServerPackageSpec/' . (int) $product->configoption2);
@@ -299,20 +306,94 @@ class Module
if ($request->getRequestInfo('http_code') == '200') {
$templates = json_decode($data, true);
$result = [];
$baseUrl = rtrim(str_replace('/api/v1', '', $cp['url']), '/');
$categories = [];
$otherTemplates = [];
if (isset($templates['data'])) {
foreach ($templates['data'] as $osCategory) {
$catTemplates = [];
foreach ($osCategory['templates'] as $template) {
$result[] = [
$catTemplates[] = [
'id' => $template['id'],
'name' => $template['name'] . ' ' . $template['version'] . ' ' . $template['variant'],
'name' => $template['name'],
'version' => $template['version'] ?? '',
'variant' => $template['variant'] ?? '',
'icon' => $template['icon'] ?? null,
'eol' => $template['eol'] ?? false,
'type' => $template['type'] ?? '',
'description' => $template['description'] ?? '',
];
}
if (count($catTemplates) <= 1) {
$otherTemplates = array_merge($otherTemplates, $catTemplates);
} else {
$categories[] = [
'name' => $osCategory['name'] ?? 'Unknown',
'icon' => $osCategory['icon'] ?? null,
'templates' => $catTemplates,
];
}
}
usort($result, function ($a, $b) {
return strcmp($a['name'], $b['name']);
});
if (!empty($otherTemplates)) {
$categories[] = [
'name' => 'Other',
'icon' => null,
'templates' => $otherTemplates,
];
}
}
$result = [
'baseUrl' => $baseUrl,
'categories' => $categories,
];
Cache::set($cacheKey, $result, 600);
return $result;
}
}
return false;
}
// =========================================================================
// Traffic Statistics
// =========================================================================
/**
* Get traffic statistics for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getTrafficStats($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$cacheKey = 'traffic:' . (int) $service->server_id;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/servers/' . (int) $service->server_id . '/traffic');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 120);
return $result;
}
}
@@ -354,117 +435,48 @@ class Module
return false;
}
/**
* Remove an IPv4 address from a server.
*
* @param int $serviceID
* @param string $ipAddress The IPv4 address to remove
* @return object|false
*/
public function removeIPv4($serviceID, $ipAddress)
{
$serviceID = (int) $serviceID;
$ipAddress = filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
if (!$ipAddress) {
return false;
}
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(['address' => $ipAddress]));
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id . '/ipv4');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Add an IPv6 subnet to a server.
*
* @param int $serviceID
* @return object|false
*/
public function addIPv6($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/ipv6');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Remove an IPv6 subnet from a server.
*
* @param int $serviceID
* @param string $subnet The IPv6 subnet to remove
* @return object|false
*/
public function removeIPv6($serviceID, $subnet)
{
$serviceID = (int) $serviceID;
$subnet = trim($subnet);
if (empty($subnet)) {
return false;
}
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(['subnet' => $subnet]));
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id . '/ipv6');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
// =========================================================================
// Backup Management
// =========================================================================
/**
* Get backup list for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getServerBackups($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$cacheKey = 'backups:' . (int) $service->server_id;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/backups/server/' . (int) $service->server_id);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 120);
return $result;
}
}
return false;
}
/**
* Assign a backup plan to a server.
*
@@ -539,6 +551,39 @@ class Module
return false;
}
/**
* Toggle VNC on/off for a server.
*
* @param int $serviceID
* @param bool $enabled
* @return array|false
*/
public function toggleVnc($serviceID, $enabled)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(['enabled' => (bool) $enabled]));
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/vnc');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data, true) ?: ['success' => true];
}
}
return false;
}
// =========================================================================
// Resource Modification
// =========================================================================
@@ -630,6 +675,37 @@ class Module
return ['valid' => false, 'errors' => $errors];
}
/**
* Reset the server's root password.
*
* @param int $serviceID
* @return array|false
*/
public function resetServerPassword($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/resetPassword');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data, true);
}
}
return false;
}
public function resetUserPassword($serviceID, $clientID)
{
$serviceID = (int) $serviceID;
@@ -839,6 +915,12 @@ class Module
*/
public function getSelfServiceCurrencies($serviceID)
{
$cacheKey = 'ss_currencies';
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
@@ -852,7 +934,9 @@ class Module
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 1800);
return $result;
}
return false;
}