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:
131
modules/servers/VirtFusionDirect/lib/Cache.php
Normal file
131
modules/servers/VirtFusionDirect/lib/Cache.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user