Some checks failed
Publish Release / release (push) Failing after 16s
Major client-area overhaul, WHMCS 9 + VirtFusion v7 compatibility, and a
hardening pass on every destructive client.php endpoint.
Tested against WHMCS 9.0.3 + VirtFusion v7.0.0 Build 9.
Features
- "On This Page" jump-link group injected into the WHMCS Actions sidebar
via ClientAreaPrimarySidebar; auto-hides links for hidden panels.
- Monthly traffic chart (last 12 months) with rx/tx bars and centered
legend; replaces the dead canvas that read non-existent JSON paths.
- Live Stats panel: CPU, memory, disk I/O from remoteState; 30s refresh
while the panel is visible AND the page has focus.
- Filesystem usage rows in the Resources panel from qemu-guest-agent
fsinfo; pseudo-FS filtered out.
- Server Overview meta chips: data-center location with country flag,
OS template/agent name with kernel on hover, "Created N days ago".
- Hypervisor maintenance banner at the top of the page.
- Mask Sensitive screenshot mode: IPv4 keeps first two octets, IPv6
keeps first two hextets, hostnames keep first char per dot-label.
Inputs masked via text-security: disc; covers Server Name + Hostname
+ IP cells + rDNS panel rows.
- Per-IP copy buttons folded into the Server Overview cells (replaces
the deleted standalone Network panel).
- VNC viewer popup served from a same-origin authenticated route
(client.php?action=vncViewer) — POST + requireSameOrigin, rotates
the wss token on every open, X-Frame-Options DENY, strict CSP.
Bug Fixes
- UsageUpdate cron silently no-op'd: read server.usage.traffic.used
which doesn't exist. Bandwidth now from /servers/{id}/traffic;
disk usage from remoteState.agent.fsinfo.
- WHMCS 9 multi-service order short-circuit: AfterModuleCreate's
AcceptOrder fired after the first service and terminated the batch
loop, orphaning siblings. Defer until every VF service in the order
has a server_id.
- Orphaned services produced six generic 500s; new
requireProvisionedService() helper emits one clean 409 with an
actionable message. Wired into all 17 client.php cases.
- Server Overview Traffic showed "- / Unlimited"; now renders real
bytes and "Unmetered" (limit=0 is per-period uncapped, not feature-off).
- Rename endpoint moved to PUT /servers/{id}/modify/name in VF v7
(was 404'ing); response is HTTP 201 not 200/204.
- Rename was force-lowercasing the input; relaxed validation to
preserve case + freeze the input row mid-flight to prevent
double-submits.
- "Other" OS category icon override removed; uses VirtFusion's icon
instead of a hardcoded SVG.
- Save button squish on the rename row fixed via flex-wrap layout.
Security
- CSRF protection (requirePost + requireSameOrigin) added to every
destructive POST: rebuild, resetPassword, resetServerPassword,
powerAction, rename, selfServiceAddCredit, toggleVnc, vncViewer.
Previously only rdnsUpdate had it.
- Open-redirect defence in Module::fetchLoginTokens — refuses to
return a redirect URL whose host doesn't match the configured VF
panel hostname.
- Per-action rate limiting via new Module::requireRateLimit helper
(Cache-backed): rebuild 60s, resetPassword/resetServerPassword 30s,
powerAction 10s, vncViewer/toggleVnc/selfServiceAddCredit 5s.
- vncViewer route delivers strict Content-Security-Policy
(default-src none, script-src self + VF panel, connect-src wss VF
panel, frame-ancestors none).
- IPv6 examples in placeholder/comments switched to the IANA
documentation prefix 2001:db8::/32 (RFC 3849).
Removed
- Network panel (duplicated Server Overview IP rows).
- VNC enable/disable toggle (VF firewall flag is non-functional;
toggle was misleading).
- Network Speed row in Resources panel (always 0 from VF API).
Internal
- Module::fetchServerData now passes ?remoteState=true.
- ServerResource::process exposes osName/osPretty/osKernel/osDistro/
osIcon/location/locationIcon/hypervisorMaintenance/createdAt/
builtAt/live.* fields.
- Module::toggleVnc corrected to send {vnc:bool} (the actual API
param) instead of {enabled:bool} (silent no-op).
- Module::getVncConsole + toggleVnc return baseUrl alongside the
envelope so the viewer route can build the wss URL.
- Panel margins tightened mb-3 → mb-2 across all 11 panels.
929 lines
41 KiB
PHP
929 lines
41 KiB
PHP
<?php
|
|
|
|
/**
|
|
* WHMCS hooks for the VirtFusion module.
|
|
*
|
|
* HOW HOOKS WORK IN WHMCS
|
|
* -----------------------
|
|
* add_hook('EventName', $priority, $callback) registers $callback to fire on
|
|
* the named event. WHMCS discovers hook files by walking modules/servers/*
|
|
* /hooks.php and modules/addons/* /hooks.php on every page load, then invokes
|
|
* every registered hook for the current event.
|
|
*
|
|
* Hooks run IN-REQUEST — there's no queue or background worker. Anything
|
|
* expensive in a hook (like an external API call) blocks the user's page
|
|
* load. For that reason we only do:
|
|
* - Fast in-process work (building DOM snippets, validating session state)
|
|
* - Scheduled work on DailyCronJob where "in-request" means the cron worker,
|
|
* not a user session
|
|
*
|
|
* HOOKS REGISTERED HERE
|
|
* ---------------------
|
|
|
|
* DailyCronJob — PowerDNS reconciliation across all services
|
|
* AfterCronJob — Every-2-hour stock recalculation safety net
|
|
* AfterModuleCreate — Stock refresh + order auto-accept after a VPS provisions
|
|
* AfterModuleTerminate — Stock refresh after a VPS is destroyed
|
|
* ClientAreaPageCart — Lazy per-product stock refresh during the order flow
|
|
* ShoppingCartValidateCheckout — blocks checkout until OS is selected
|
|
* ClientAreaFooterOutput — injects the OS/SSH-key gallery on order form
|
|
*
|
|
* FAILURE SEMANTICS
|
|
* -----------------
|
|
* Every hook wraps its body in try/catch and silently absorbs any exception.
|
|
* A hook that throws would potentially break the entire WHMCS request for
|
|
* all users, not just this module — so we log and swallow, preferring
|
|
* degraded functionality over site-wide breakage.
|
|
*/
|
|
|
|
use WHMCS\Database\Capsule;
|
|
use WHMCS\Module\Server\VirtFusionDirect\Cache;
|
|
use WHMCS\Module\Server\VirtFusionDirect\ConfigureService;
|
|
use WHMCS\Module\Server\VirtFusionDirect\Database;
|
|
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
|
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
|
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\PtrManager;
|
|
use WHMCS\Module\Server\VirtFusionDirect\StockControl;
|
|
|
|
if (! defined('WHMCS')) {
|
|
exit('This file cannot be accessed directly');
|
|
}
|
|
|
|
/**
|
|
* Daily PowerDNS reconciliation.
|
|
*
|
|
* Walks every managed service and creates any missing PTRs (never overwrites existing
|
|
* values — cron is additive-only). Requires the VirtFusion DNS addon to be activated
|
|
* and enabled; otherwise short-circuits immediately.
|
|
*
|
|
* All error handling lives inside reconcileAll(); this wrapper just logs any escape
|
|
* without disturbing the rest of the daily cron run.
|
|
*/
|
|
add_hook('DailyCronJob', 1, function ($vars) {
|
|
try {
|
|
if (PowerDnsConfig::isEnabled()) {
|
|
(new PtrManager)->reconcileAll();
|
|
}
|
|
} catch (Throwable $e) {
|
|
Log::insert('PowerDns:DailyCronJob', [], $e->getMessage());
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Every-~2-hour stock recalculation safety net.
|
|
*
|
|
* Events (AfterModuleCreate/Terminate) cover every capacity change driven
|
|
* through WHMCS. But an operator can also create/destroy VMs directly in the
|
|
* VirtFusion panel — no WHMCS hook fires for that, so stock qty would drift
|
|
* until the next cart-page visit or the next event-driven refresh. This hook
|
|
* closes that blind spot.
|
|
*
|
|
* AfterCronJob fires on every main WHMCS cron invocation (typically every
|
|
* 5 minutes). Cache::get on the rate-limit key means the hook is effectively
|
|
* free on the 99% of invocations where no recalc is due — one cache read,
|
|
* return. The actual recalc only runs when the key has expired.
|
|
*
|
|
* Interval: 2 hours. Tunable via the STOCK_CRON_INTERVAL_SECONDS constant
|
|
* below. Short enough that out-of-band VirtFusion panel changes surface the
|
|
* same business day; long enough that the storefront isn't writing
|
|
* tblproducts.qty every five minutes.
|
|
*
|
|
* FAIL-SAFE: StockControl::recalculateAll() returns a map of productId =>
|
|
* qty|null, where null means the orchestrator left qty UNTOUCHED (transient
|
|
* API failure, missing CP, etc.). Our catch here only fires on truly unexpected
|
|
* errors that escape the orchestrator itself.
|
|
*/
|
|
const STOCK_CRON_INTERVAL_SECONDS = 2 * 3600; // 2 hours
|
|
|
|
add_hook('AfterCronJob', 5, function ($vars) {
|
|
try {
|
|
$rateKey = 'stockrefresh:cron';
|
|
if (Cache::get($rateKey) !== null) {
|
|
return;
|
|
}
|
|
Cache::set($rateKey, 1, STOCK_CRON_INTERVAL_SECONDS);
|
|
|
|
(new StockControl)->recalculateAll();
|
|
} catch (Throwable $e) {
|
|
Log::insert('StockControl:AfterCronJob', [], $e->getMessage());
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Post-provision: auto-accept the originating order and refresh stock.
|
|
*
|
|
* Fires after every successful VirtFusion CreateAccount. Two responsibilities,
|
|
* independent try/catch blocks so a failure in one doesn't short-circuit the other:
|
|
*
|
|
* 1. AUTO-ACCEPT — if the service's parent order is still 'Pending' (admin
|
|
* hasn't manually accepted yet), call WHMCS's AcceptOrder API with
|
|
* autosetup=false (we already provisioned, don't re-trigger CreateAccount).
|
|
* This closes the loop for installs that rely on pending-order workflows
|
|
* for non-VF products but want VF provisions to auto-advance.
|
|
*
|
|
* 2. STOCK REFRESH — a new VM just consumed memory/cpu/disk/IPv4 on the
|
|
* target hypervisor group. Bust the grpres:{id} cache and recalculate
|
|
* every stock-controlled product. A shared 30 s rate-limit key prevents
|
|
* a burst of 10 parallel provisions from triggering 10 full recalcs.
|
|
*
|
|
* Filtering by moduletype='VirtFusionDirect' keeps this hook harmless for
|
|
* unrelated products that happen to share the WHMCS install.
|
|
*/
|
|
add_hook('AfterModuleCreate', 1, function ($vars) {
|
|
if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') {
|
|
return;
|
|
}
|
|
|
|
// Part 1: auto-accept the originating order if still Pending.
|
|
try {
|
|
$serviceId = (int) ($vars['params']['serviceid'] ?? 0);
|
|
if ($serviceId > 0) {
|
|
$hosting = Capsule::table('tblhosting')->where('id', $serviceId)->first();
|
|
$orderId = $hosting ? (int) ($hosting->orderid ?? 0) : 0;
|
|
if ($orderId > 0) {
|
|
$order = Capsule::table('tblorders')->where('id', $orderId)->first();
|
|
if ($order && strcasecmp((string) $order->status, 'Pending') === 0) {
|
|
// WHMCS 9 regression guard: WHMCS 9's batch order-acceptance
|
|
// loop terminates once the order leaves Pending status.
|
|
// Calling AcceptOrder after the first sibling completes
|
|
// therefore short-circuits provisioning of the rest of the
|
|
// order's services — they end up Active in tblhosting with
|
|
// no mod_virtfusion_direct row and no server in VirtFusion.
|
|
// Defer the AcceptOrder until every VF service in this
|
|
// order has provisioned; the hook fires once per service,
|
|
// so the last one to complete will see no unprovisioned
|
|
// siblings and trigger the accept. WHMCS 8 wasn't affected
|
|
// (its loop ignored order status mid-batch), but deferring
|
|
// there is harmless — same end state, just later timing.
|
|
$unprovisionedSiblings = Capsule::table('tblhosting AS h')
|
|
->join('tblproducts AS p', 'h.packageid', '=', 'p.id')
|
|
->leftJoin('mod_virtfusion_direct AS m', 'h.id', '=', 'm.service_id')
|
|
->where('h.orderid', $orderId)
|
|
->where('h.id', '!=', $serviceId)
|
|
->where('p.servertype', 'VirtFusionDirect')
|
|
->where('h.domainstatus', 'Pending')
|
|
->whereNull('m.server_id')
|
|
->count();
|
|
|
|
if ($unprovisionedSiblings > 0) {
|
|
Log::insert(
|
|
'AutoAcceptOrder:deferred',
|
|
['orderid' => $orderId, 'serviceid' => $serviceId, 'unprovisioned_siblings' => $unprovisionedSiblings],
|
|
'Order has more VirtFusionDirect services awaiting provisioning; AcceptOrder will fire after the last one',
|
|
);
|
|
} else {
|
|
$resp = localAPI('AcceptOrder', [
|
|
'orderid' => $orderId,
|
|
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
|
'sendemail' => true,
|
|
]);
|
|
Log::insert(
|
|
'AutoAcceptOrder',
|
|
['orderid' => $orderId, 'serviceid' => $serviceId],
|
|
$resp,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (Throwable $e) {
|
|
Log::insert('AutoAcceptOrder:fail', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
|
}
|
|
|
|
// Part 2: refresh stock (capacity just decreased).
|
|
try {
|
|
if (Cache::get('stockrefresh:event') === null) {
|
|
Cache::set('stockrefresh:event', 1, 30);
|
|
|
|
$groupId = (int) ($vars['params']['configoption1'] ?? 0);
|
|
if ($groupId > 0) {
|
|
Cache::forget('grpres:' . $groupId);
|
|
}
|
|
|
|
(new StockControl)->recalculateAll();
|
|
}
|
|
} catch (Throwable $e) {
|
|
Log::insert('StockControl:AfterModuleCreate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Post-termination stock refresh.
|
|
*
|
|
* A destroyed VM just freed memory/cpu/disk/IPv4 on the target hypervisor group.
|
|
* Refresh so the storefront reflects the restored capacity immediately. Shares
|
|
* the 30 s rate-limit key with AfterModuleCreate — a provision-then-terminate in
|
|
* quick succession only triggers one full recalc.
|
|
*/
|
|
add_hook('AfterModuleTerminate', 1, function ($vars) {
|
|
if (($vars['params']['moduletype'] ?? '') !== 'VirtFusionDirect') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (Cache::get('stockrefresh:event') !== null) {
|
|
return;
|
|
}
|
|
Cache::set('stockrefresh:event', 1, 30);
|
|
|
|
$groupId = (int) ($vars['params']['configoption1'] ?? 0);
|
|
if ($groupId > 0) {
|
|
Cache::forget('grpres:' . $groupId);
|
|
}
|
|
|
|
(new StockControl)->recalculateAll();
|
|
} catch (Throwable $e) {
|
|
Log::insert('StockControl:AfterModuleTerminate', ['serviceID' => $vars['params']['serviceid'] ?? null], $e->getMessage());
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Lazy stock refresh on order-flow cart pages.
|
|
*
|
|
* Keeps "hot" products fresh between daily cron runs without a polling loop: when a
|
|
* customer lands on a cart page for a specific product, we opportunistically recalculate
|
|
* that product's qty. If the upstream grpres:{id} cache is warm (populated in the last
|
|
* 120 s by an earlier view or the daily cron), recalculateForProduct does no HTTP calls
|
|
* and just re-writes the same qty — effectively free.
|
|
*
|
|
* WHY ClientAreaPageCart (not ClientAreaPageProductDetails)
|
|
* ---------------------------------------------------------
|
|
* ClientAreaPageProductDetails fires on the My Services → product-details view for an
|
|
* EXISTING service, which is the wrong place — the stock number only matters during
|
|
* pre-order. ClientAreaPageCart fires on every cart/order page (product browse, config,
|
|
* checkout) and WHMCS consults tblproducts.qty on each of those, so this is where a
|
|
* fresh number pays off.
|
|
*
|
|
* RATE LIMIT
|
|
* ----------
|
|
* 60 s per product (stockrefresh:{pid}). Short enough that a busy product refreshes
|
|
* near-continuously across viewers; long enough that two customers arriving within the
|
|
* same second don't trigger two identical DB UPDATEs. The pid check below filters this
|
|
* hook to only fire when a specific product is known — generic cart pages (templatefile=
|
|
* "cart.tpl") pass no pid and are no-ops.
|
|
*/
|
|
add_hook('ClientAreaPageCart', 1, function ($vars) {
|
|
try {
|
|
$productId = (int) ($vars['pid'] ?? $vars['productid'] ?? ($vars['productinfo']['pid'] ?? 0));
|
|
if ($productId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$rateKey = 'stockrefresh:' . $productId;
|
|
if (Cache::get($rateKey) !== null) {
|
|
return null;
|
|
}
|
|
Cache::set($rateKey, 1, 60);
|
|
|
|
(new StockControl)->recalculateForProduct($productId);
|
|
} catch (Throwable $e) {
|
|
Log::insert('StockControl:ClientAreaPageCart', ['pid' => $vars['pid'] ?? null], $e->getMessage());
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
/**
|
|
* Shopping Cart Validation Hook
|
|
*
|
|
* Validates that an operating system has been selected before checkout
|
|
* for all VirtFusion products in the cart.
|
|
*/
|
|
add_hook('ShoppingCartValidateCheckout', 1, function ($vars) {
|
|
$errors = [];
|
|
|
|
try {
|
|
if (! isset($_SESSION['cart']['products']) || ! is_array($_SESSION['cart']['products'])) {
|
|
return $errors;
|
|
}
|
|
|
|
foreach ($_SESSION['cart']['products'] as $key => $product) {
|
|
$pid = $product['pid'] ?? null;
|
|
if (! $pid) {
|
|
continue;
|
|
}
|
|
|
|
$dbProduct = Capsule::table('tblproducts')
|
|
->where('id', $pid)
|
|
->where('servertype', 'VirtFusionDirect')
|
|
->first();
|
|
|
|
if (! $dbProduct) {
|
|
continue;
|
|
}
|
|
|
|
// Check if Initial Operating System custom field has a value
|
|
if (isset($product['customfields']) && is_array($product['customfields'])) {
|
|
$osSelected = false;
|
|
$customFields = Capsule::table('tblcustomfields')
|
|
->where('relid', $pid)
|
|
->where('type', 'product')
|
|
->get();
|
|
|
|
foreach ($customFields as $field) {
|
|
if (strtolower(str_replace(' ', '', $field->fieldname)) === 'initialoperatingsystem') {
|
|
$fieldValue = $product['customfields'][$field->id] ?? '';
|
|
if (! empty($fieldValue) && is_numeric($fieldValue)) {
|
|
$osSelected = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! $osSelected) {
|
|
$errors[] = 'Please select an Operating System for your VPS order.';
|
|
}
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
// Don't block checkout on internal errors
|
|
}
|
|
|
|
return $errors;
|
|
});
|
|
|
|
/**
|
|
* Client Area Footer Output Hook
|
|
*
|
|
* Dynamically converts hidden text fields for OS templates and SSH keys
|
|
* into dropdown selects populated from the VirtFusion API.
|
|
* Works with all WHMCS themes by using vanilla JavaScript and standard form-control classes.
|
|
*/
|
|
add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|
if (! isset($vars['productinfo']['module']) || $vars['productinfo']['module'] !== 'VirtFusionDirect') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$cs = new ConfigureService;
|
|
|
|
$templates_data = $cs->fetchTemplates(
|
|
$cs->fetchPackageByDbId($vars['productinfo']['pid']) ?? $cs->fetchPackageId($vars['productinfo']['name']),
|
|
);
|
|
|
|
if (empty($templates_data)) {
|
|
return null;
|
|
}
|
|
|
|
$vfServer = Capsule::table('tblservers')
|
|
->where('type', 'VirtFusionDirect')
|
|
->where('disabled', 0)
|
|
->first();
|
|
$baseUrl = $vfServer ? rtrim('https://' . $vfServer->hostname, '/') : '';
|
|
|
|
$galleryData = [
|
|
'baseUrl' => $baseUrl,
|
|
'categories' => Module::groupOsTemplates($templates_data['data'] ?? [], true),
|
|
];
|
|
|
|
$sshKeys = [];
|
|
$sshKeysOptions = [];
|
|
if (isset($vars['loggedinuser']) && $vars['loggedinuser']) {
|
|
$sshKeysData = $cs->getUserSshKeys($vars['loggedinuser']);
|
|
if ($sshKeysData && isset($sshKeysData['data'])) {
|
|
$sshKeysOptions = array_values(array_filter(array_map(function ($sshKey) {
|
|
if ($sshKey['enabled'] === false) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => $sshKey['id'],
|
|
'name' => htmlspecialchars($sshKey['name'], ENT_QUOTES, 'UTF-8'),
|
|
];
|
|
}, $sshKeysData['data'])));
|
|
}
|
|
}
|
|
|
|
$osID = array_values(array_filter(array_map(function ($option) {
|
|
if ($option['textid'] === 'initialoperatingsystem') {
|
|
return $option['id'];
|
|
}
|
|
}, $vars['customfields'] ?? [])));
|
|
|
|
$sshID = array_values(array_filter(array_map(function ($option) {
|
|
if ($option['textid'] === 'initialsshkey') {
|
|
return $option['id'];
|
|
}
|
|
}, $vars['customfields'] ?? [])));
|
|
|
|
$osFieldId = $osID[0] ?? null;
|
|
$sshFieldId = $sshID[0] ?? null;
|
|
|
|
if ($osFieldId === null) {
|
|
return null;
|
|
}
|
|
|
|
$systemUrl = Database::getSystemUrl();
|
|
|
|
return '
|
|
<link href="' . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . 'modules/servers/VirtFusionDirect/templates/css/module.css?v=' . time() . '" rel="stylesheet">
|
|
<script src="' . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . 'modules/servers/VirtFusionDirect/templates/js/keygen.js?v=' . time() . "\"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var osGalleryData = " . json_encode($galleryData, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';
|
|
var sshKeys = ' . json_encode($sshKeysOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
|
|
|
|
var osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
|
|
var sshInputField = " . ($sshFieldId !== null ? "document.querySelector('[name=\"customfield[" . (int) $sshFieldId . "]\"]')" : 'null') . ';
|
|
var sshInputLabel = ' . ($sshFieldId !== null ? "document.querySelector('[for=\"customfield" . (int) $sshFieldId . "\"]')" : 'null') . ";
|
|
|
|
if (!osInputField) return;
|
|
|
|
// Brand color map (must match vfOsBrandColors in module.js)
|
|
var brandColors = {
|
|
'ubuntu':'#E95420','debian':'#A81D33','rocky':'#10B981','centos':'#932279',
|
|
'almalinux':'#0F4266','alma':'#0F4266','windows':'#0078D4','fedora':'#51A2DA',
|
|
'arch':'#1793D1','opensuse':'#73BA25','suse':'#73BA25','freebsd':'#AB2B28',
|
|
'oracle':'#F80000','rhel':'#EE0000','red hat':'#EE0000','cloudlinux':'#0095D9',
|
|
'gentoo':'#54487A','slackware':'#000','nixos':'#7EBAE4','alpine':'#0D597F'
|
|
};
|
|
function getBrandColor(name) {
|
|
var l = (name || '').toLowerCase();
|
|
for (var k in brandColors) { if (l.indexOf(k) !== -1) return brandColors[k]; }
|
|
return '#6c757d';
|
|
}
|
|
|
|
// Build gallery container
|
|
var galleryWrap = document.createElement('div');
|
|
galleryWrap.style.marginTop = '8px';
|
|
|
|
var searchInput = document.createElement('input');
|
|
searchInput.type = 'text';
|
|
searchInput.className = 'form-control vf-os-search';
|
|
searchInput.placeholder = 'Search templates...';
|
|
galleryWrap.appendChild(searchInput);
|
|
|
|
var galleryContainer = document.createElement('div');
|
|
galleryContainer.setAttribute('id', 'vf-checkout-os-gallery');
|
|
galleryContainer.style.marginTop = '8px';
|
|
|
|
if (osGalleryData.categories && osGalleryData.categories.length > 0) {
|
|
osGalleryData.categories.forEach(function(cat, ci) {
|
|
var section = document.createElement('div');
|
|
section.className = 'vf-os-category';
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'vf-os-category-header';
|
|
var catColor = getBrandColor(cat.name);
|
|
|
|
var catIcon = document.createElement('span');
|
|
catIcon.className = 'vf-os-category-icon';
|
|
if (cat.icon && osGalleryData.baseUrl) {
|
|
var catImg = document.createElement('img');
|
|
catImg.src = osGalleryData.baseUrl + '/img/logo/' + encodeURIComponent(cat.icon);
|
|
catImg.alt = '';
|
|
catImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = (cat.name || '?')[0].toUpperCase(); };
|
|
catIcon.appendChild(catImg);
|
|
} else {
|
|
// No icon (synthetic singletons bucket, etc.) — fall back
|
|
// to brand-color circle with the first letter, matching
|
|
// the client-area renderer.
|
|
catIcon.style.background = catColor;
|
|
catIcon.textContent = (cat.name || '?')[0].toUpperCase();
|
|
}
|
|
|
|
var catTitle = document.createElement('span');
|
|
catTitle.textContent = cat.name + ' (' + cat.templates.length + ')';
|
|
|
|
var arrow = document.createElement('span');
|
|
arrow.className = 'vf-os-category-arrow';
|
|
arrow.textContent = ci === 0 ? '\u25BC' : '\u25B6';
|
|
|
|
header.appendChild(catIcon);
|
|
header.appendChild(catTitle);
|
|
header.appendChild(arrow);
|
|
section.appendChild(header);
|
|
|
|
var grid = document.createElement('div');
|
|
grid.className = 'vf-os-grid';
|
|
if (ci !== 0) grid.style.display = 'none';
|
|
|
|
header.addEventListener('click', function() {
|
|
var isOpen = grid.style.display !== 'none';
|
|
// Collapse all
|
|
galleryContainer.querySelectorAll('.vf-os-grid').forEach(function(g) { g.style.display = 'none'; });
|
|
galleryContainer.querySelectorAll('.vf-os-category-arrow').forEach(function(a) { a.textContent = '\u25B6'; });
|
|
// Toggle this one
|
|
if (!isOpen) {
|
|
grid.style.display = '';
|
|
arrow.textContent = '\u25BC';
|
|
}
|
|
});
|
|
|
|
cat.templates.forEach(function(tpl) {
|
|
var fullLabel = tpl.name + (tpl.version ? ' ' + tpl.version : '') + (tpl.variant ? ' ' + tpl.variant : '');
|
|
var card = document.createElement('div');
|
|
card.className = 'vf-os-card' + (tpl.eol ? ' vf-os-card-eol' : '');
|
|
card.setAttribute('data-id', tpl.id);
|
|
card.setAttribute('data-search', fullLabel.toLowerCase());
|
|
|
|
var iconDiv = document.createElement('div');
|
|
iconDiv.className = 'vf-os-icon';
|
|
if (tpl.icon && osGalleryData.baseUrl) {
|
|
var tplImg = document.createElement('img');
|
|
tplImg.src = osGalleryData.baseUrl + '/img/logo/' + encodeURIComponent(tpl.icon);
|
|
tplImg.alt = '';
|
|
tplImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = ''; var s = document.createElement('span'); s.textContent = (tpl.name || '?')[0].toUpperCase(); this.parentNode.appendChild(s); };
|
|
iconDiv.appendChild(tplImg);
|
|
} else {
|
|
iconDiv.style.background = catColor;
|
|
var sp = document.createElement('span');
|
|
sp.textContent = (tpl.name || '?')[0].toUpperCase();
|
|
iconDiv.appendChild(sp);
|
|
}
|
|
card.appendChild(iconDiv);
|
|
|
|
var labelDiv = document.createElement('div');
|
|
labelDiv.className = 'vf-os-label';
|
|
labelDiv.textContent = tpl.name;
|
|
card.appendChild(labelDiv);
|
|
|
|
var verDiv = document.createElement('div');
|
|
verDiv.className = 'vf-os-version';
|
|
verDiv.textContent = (tpl.version || '') + (tpl.variant ? ' ' + tpl.variant : '');
|
|
card.appendChild(verDiv);
|
|
|
|
if (tpl.eol) {
|
|
var eolBadge = document.createElement('span');
|
|
eolBadge.className = 'vf-os-eol-badge';
|
|
eolBadge.textContent = 'EOL';
|
|
card.appendChild(eolBadge);
|
|
}
|
|
|
|
card.addEventListener('click', function() {
|
|
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) { c.classList.remove('vf-os-card-selected'); });
|
|
card.classList.add('vf-os-card-selected');
|
|
osInputField.value = tpl.id;
|
|
galleryContainer.style.borderColor = '';
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
section.appendChild(grid);
|
|
galleryContainer.appendChild(section);
|
|
});
|
|
}
|
|
|
|
galleryWrap.appendChild(galleryContainer);
|
|
|
|
// Search handler
|
|
searchInput.addEventListener('keyup', function() {
|
|
var q = this.value.toLowerCase();
|
|
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) {
|
|
c.style.display = c.getAttribute('data-search').indexOf(q) !== -1 ? '' : 'none';
|
|
});
|
|
galleryContainer.querySelectorAll('.vf-os-category').forEach(function(s) {
|
|
var cards = s.querySelectorAll('.vf-os-card');
|
|
var hasVisible = false;
|
|
cards.forEach(function(c) { if (c.style.display !== 'none') hasVisible = true; });
|
|
s.style.display = hasVisible ? '' : 'none';
|
|
});
|
|
});
|
|
|
|
// Validation: red border if no selection on form submit
|
|
var form = osInputField.closest('form');
|
|
if (form) {
|
|
form.addEventListener('submit', function(e) {
|
|
if (!osInputField.value) {
|
|
galleryContainer.style.border = '2px solid #dc3545';
|
|
galleryContainer.style.borderRadius = '8px';
|
|
galleryContainer.style.padding = '4px';
|
|
galleryContainer.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
}
|
|
});
|
|
}
|
|
|
|
osInputField.parentNode.insertBefore(galleryWrap, osInputField.nextSibling);
|
|
osInputField.style.display = 'none';
|
|
|
|
// Handle SSH keys
|
|
if (sshInputField) {
|
|
// Create the paste-key textarea (hidden initially if keys exist)
|
|
var sshPasteContainer = document.createElement('div');
|
|
sshPasteContainer.setAttribute('id', 'vf-ssh-paste-container');
|
|
sshPasteContainer.style.display = 'none';
|
|
sshPasteContainer.style.marginTop = '8px';
|
|
|
|
var pasteLabel = document.createElement('label');
|
|
pasteLabel.textContent = 'Paste your SSH public key:';
|
|
pasteLabel.style.display = 'block';
|
|
pasteLabel.style.marginBottom = '4px';
|
|
|
|
var pasteArea = document.createElement('textarea');
|
|
pasteArea.className = 'form-control';
|
|
pasteArea.setAttribute('id', 'vf-ssh-paste');
|
|
pasteArea.setAttribute('rows', '3');
|
|
pasteArea.setAttribute('placeholder', 'ssh-rsa AAAA... or ssh-ed25519 AAAA...');
|
|
|
|
pasteArea.addEventListener('input', function() {
|
|
sshInputField.value = this.value.trim();
|
|
});
|
|
|
|
sshPasteContainer.appendChild(pasteLabel);
|
|
sshPasteContainer.appendChild(pasteArea);
|
|
|
|
// Generate key button
|
|
var generateBtn = document.createElement('button');
|
|
generateBtn.type = 'button';
|
|
generateBtn.className = 'btn btn-outline-secondary btn-sm';
|
|
generateBtn.textContent = 'Generate a new key';
|
|
generateBtn.style.marginTop = '8px';
|
|
|
|
// Private key panel (hidden initially)
|
|
var privKeyPanel = document.createElement('div');
|
|
privKeyPanel.setAttribute('id', 'vf-privkey-panel');
|
|
privKeyPanel.style.display = 'none';
|
|
privKeyPanel.style.marginTop = '12px';
|
|
privKeyPanel.style.border = '2px solid #dc3545';
|
|
privKeyPanel.style.borderRadius = '6px';
|
|
privKeyPanel.style.padding = '12px';
|
|
|
|
var privKeyWarning = document.createElement('div');
|
|
privKeyWarning.style.color = '#dc3545';
|
|
privKeyWarning.style.fontWeight = 'bold';
|
|
privKeyWarning.style.marginBottom = '8px';
|
|
privKeyWarning.textContent = 'Private Key — Save This Now! It will not be shown again.';
|
|
|
|
var privKeyArea = document.createElement('textarea');
|
|
privKeyArea.className = 'form-control';
|
|
privKeyArea.setAttribute('rows', '6');
|
|
privKeyArea.setAttribute('readonly', 'readonly');
|
|
privKeyArea.style.fontFamily = 'monospace';
|
|
privKeyArea.style.fontSize = '12px';
|
|
privKeyArea.style.marginBottom = '8px';
|
|
|
|
var privKeyBtnRow = document.createElement('div');
|
|
privKeyBtnRow.style.display = 'flex';
|
|
privKeyBtnRow.style.gap = '8px';
|
|
privKeyBtnRow.style.alignItems = 'center';
|
|
privKeyBtnRow.style.flexWrap = 'wrap';
|
|
|
|
var downloadBtn = document.createElement('button');
|
|
downloadBtn.type = 'button';
|
|
downloadBtn.className = 'btn btn-primary btn-sm';
|
|
downloadBtn.textContent = 'Download';
|
|
|
|
var copyBtn = document.createElement('button');
|
|
copyBtn.type = 'button';
|
|
copyBtn.className = 'btn btn-default btn-secondary btn-sm';
|
|
copyBtn.textContent = 'Copy to Clipboard';
|
|
|
|
var pubKeyConfirm = document.createElement('span');
|
|
pubKeyConfirm.style.color = '#28a745';
|
|
pubKeyConfirm.style.fontWeight = 'bold';
|
|
pubKeyConfirm.textContent = 'Public key set automatically.';
|
|
|
|
privKeyBtnRow.appendChild(downloadBtn);
|
|
privKeyBtnRow.appendChild(copyBtn);
|
|
privKeyBtnRow.appendChild(pubKeyConfirm);
|
|
privKeyPanel.appendChild(privKeyWarning);
|
|
privKeyPanel.appendChild(privKeyArea);
|
|
privKeyPanel.appendChild(privKeyBtnRow);
|
|
|
|
downloadBtn.addEventListener('click', function() {
|
|
vfDownloadFile('id_ed25519', privKeyArea.value);
|
|
});
|
|
|
|
copyBtn.addEventListener('click', function() {
|
|
navigator.clipboard.writeText(privKeyArea.value).then(function() {
|
|
copyBtn.textContent = 'Copied!';
|
|
setTimeout(function() { copyBtn.textContent = 'Copy to Clipboard'; }, 2000);
|
|
});
|
|
});
|
|
|
|
// Error message for unsupported browsers
|
|
var genErrorMsg = document.createElement('div');
|
|
genErrorMsg.style.display = 'none';
|
|
genErrorMsg.style.marginTop = '8px';
|
|
genErrorMsg.style.color = '#dc3545';
|
|
genErrorMsg.textContent = 'Your browser does not support Ed25519 key generation. Please paste your public key manually.';
|
|
|
|
generateBtn.addEventListener('click', async function() {
|
|
generateBtn.disabled = true;
|
|
generateBtn.textContent = 'Generating...';
|
|
try {
|
|
var keys = await vfGenerateSSHKey();
|
|
var sshSelect = document.getElementById('vf-ssh-select');
|
|
if (sshSelect) {
|
|
sshSelect.value = '__new__';
|
|
sshPasteContainer.style.display = 'block';
|
|
}
|
|
pasteArea.value = keys.publicKey;
|
|
sshInputField.value = keys.publicKey;
|
|
privKeyArea.value = keys.privateKey;
|
|
privKeyPanel.style.display = 'block';
|
|
genErrorMsg.style.display = 'none';
|
|
} catch (e) {
|
|
genErrorMsg.style.display = 'block';
|
|
privKeyPanel.style.display = 'none';
|
|
} finally {
|
|
generateBtn.disabled = false;
|
|
generateBtn.textContent = 'Generate a new key';
|
|
}
|
|
});
|
|
|
|
if (sshKeys.length > 0) {
|
|
var sshSelect = document.createElement('select');
|
|
sshSelect.className = 'form-control';
|
|
sshSelect.setAttribute('id', 'vf-ssh-select');
|
|
|
|
var sshDefaultOption = document.createElement('option');
|
|
sshDefaultOption.value = '';
|
|
sshDefaultOption.text = '-- No SSH Key (Optional) --';
|
|
sshSelect.appendChild(sshDefaultOption);
|
|
|
|
sshKeys.forEach(function(sshkey) {
|
|
var option = document.createElement('option');
|
|
option.value = sshkey.id;
|
|
option.text = sshkey.name;
|
|
sshSelect.appendChild(option);
|
|
});
|
|
|
|
// Add new key option
|
|
var addNewOption = document.createElement('option');
|
|
addNewOption.value = '__new__';
|
|
addNewOption.text = 'Add new key...';
|
|
sshSelect.appendChild(addNewOption);
|
|
|
|
sshSelect.addEventListener('change', function() {
|
|
if (this.value === '__new__') {
|
|
sshPasteContainer.style.display = 'block';
|
|
sshInputField.value = '';
|
|
} else {
|
|
sshPasteContainer.style.display = 'none';
|
|
document.getElementById('vf-ssh-paste').value = '';
|
|
sshInputField.value = this.value;
|
|
}
|
|
});
|
|
|
|
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling);
|
|
sshSelect.parentNode.insertBefore(sshPasteContainer, sshSelect.nextSibling);
|
|
sshPasteContainer.parentNode.insertBefore(generateBtn, sshPasteContainer.nextSibling);
|
|
generateBtn.parentNode.insertBefore(genErrorMsg, generateBtn.nextSibling);
|
|
genErrorMsg.parentNode.insertBefore(privKeyPanel, genErrorMsg.nextSibling);
|
|
sshInputField.style.display = 'none';
|
|
} else {
|
|
// No existing keys — show the paste textarea directly
|
|
sshPasteContainer.style.display = 'block';
|
|
sshInputField.parentNode.insertBefore(sshPasteContainer, sshInputField.nextSibling);
|
|
sshPasteContainer.parentNode.insertBefore(generateBtn, sshPasteContainer.nextSibling);
|
|
generateBtn.parentNode.insertBefore(genErrorMsg, generateBtn.nextSibling);
|
|
genErrorMsg.parentNode.insertBefore(privKeyPanel, genErrorMsg.nextSibling);
|
|
sshInputField.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Slider UI: enhance known configurable option selects with range sliders
|
|
var sliderResourceNames = ['Memory', 'CPU Cores', 'Storage', 'Bandwidth', 'Inbound Network Speed', 'Outbound Network Speed'];
|
|
var sliderUnits = {
|
|
'Memory': 'MB', 'CPU Cores': 'Core(s)', 'Storage': 'GB',
|
|
'Bandwidth': 'GB', 'Inbound Network Speed': 'Mbps', 'Outbound Network Speed': 'Mbps'
|
|
};
|
|
|
|
var configSelects = document.querySelectorAll('select[name^=\"configoption[\"]');
|
|
configSelects.forEach(function(sel) {
|
|
// Find the label for this select
|
|
var label = null;
|
|
var labelEl = sel.closest('.form-group, .row');
|
|
if (labelEl) {
|
|
label = labelEl.querySelector('label');
|
|
}
|
|
if (!label) return;
|
|
|
|
var labelText = label.textContent.trim();
|
|
var matchedResource = null;
|
|
sliderResourceNames.forEach(function(name) {
|
|
if (labelText.indexOf(name) !== -1) {
|
|
matchedResource = name;
|
|
}
|
|
});
|
|
if (!matchedResource) return;
|
|
|
|
var options = [];
|
|
for (var i = 0; i < sel.options.length; i++) {
|
|
options.push({
|
|
value: sel.options[i].value,
|
|
label: sel.options[i].text
|
|
});
|
|
}
|
|
if (options.length < 2) return;
|
|
|
|
var unit = sliderUnits[matchedResource] || '';
|
|
|
|
// Create slider container
|
|
var container = document.createElement('div');
|
|
container.className = 'vf-slider-container';
|
|
|
|
var valueDisplay = document.createElement('div');
|
|
valueDisplay.className = 'vf-slider-value';
|
|
valueDisplay.textContent = options[sel.selectedIndex || 0].label + (unit ? ' ' + unit : '');
|
|
|
|
var slider = document.createElement('input');
|
|
slider.type = 'range';
|
|
slider.className = 'vf-slider form-range';
|
|
slider.min = '0';
|
|
slider.max = String(options.length - 1);
|
|
slider.step = '1';
|
|
slider.value = String(sel.selectedIndex || 0);
|
|
|
|
slider.addEventListener('input', function() {
|
|
var idx = parseInt(this.value);
|
|
sel.selectedIndex = idx;
|
|
valueDisplay.textContent = options[idx].label + (unit ? ' ' + unit : '');
|
|
// Trigger change event on hidden select for WHMCS pricing
|
|
var evt = new Event('change', { bubbles: true });
|
|
sel.dispatchEvent(evt);
|
|
});
|
|
|
|
container.appendChild(valueDisplay);
|
|
container.appendChild(slider);
|
|
|
|
sel.parentNode.insertBefore(container, sel.nextSibling);
|
|
sel.style.display = 'none';
|
|
});
|
|
});
|
|
</script>
|
|
";
|
|
} catch (Throwable $e) {
|
|
// Silently fail - don't break the checkout page
|
|
return null;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Inject a "On This Page" jump-link group into the client area sidebar
|
|
* when the customer is viewing a VirtFusionDirect product details page.
|
|
*
|
|
* Replaces the previous inline horizontal nav strip — sidebar placement
|
|
* keeps the links visible while scrolling the long product details page.
|
|
*
|
|
* Static rendering: every known section anchor is added regardless of
|
|
* whether its panel is visible. JS (vfBuildSectionNav in module.js) walks
|
|
* the rendered links post-load and hides the parent <li> for any target
|
|
* panel that isn't visible (Resources/VNC/Self-Service when their data
|
|
* hasn't loaded; rDNS when PowerDNS isn't enabled at the template level).
|
|
*
|
|
* Filtered to productdetails for VF services so we don't pollute the
|
|
* sidebar on unrelated pages or non-VF service detail pages.
|
|
*/
|
|
add_hook('ClientAreaPrimarySidebar', 1, function ($primarySidebar) {
|
|
try {
|
|
$action = $_REQUEST['action'] ?? '';
|
|
$serviceId = (int) ($_REQUEST['id'] ?? 0);
|
|
if ($action !== 'productdetails' || $serviceId <= 0) {
|
|
return;
|
|
}
|
|
|
|
// Verify this is a VirtFusionDirect service before adding our links.
|
|
$isVf = Capsule::table('tblhosting AS h')
|
|
->join('tblproducts AS p', 'h.packageid', '=', 'p.id')
|
|
->where('h.id', $serviceId)
|
|
->where('p.servertype', 'VirtFusionDirect')
|
|
->exists();
|
|
if (! $isVf) {
|
|
return;
|
|
}
|
|
|
|
// High order pushes us below the standard "Manage Product" entries.
|
|
$jump = $primarySidebar->addChild('VfJumpTo', [
|
|
'label' => 'On This Page',
|
|
'order' => 80,
|
|
]);
|
|
|
|
// VNC deliberately excluded — its panel sits at the very top of
|
|
// the page, so a sidebar jump-link would just scroll the customer
|
|
// past everything else they care about. The other entries are
|
|
// ordered to match the page's vertical flow.
|
|
$items = [
|
|
['Overview', 'vf-sec-overview'],
|
|
['Traffic', 'vf-sec-traffic'],
|
|
['Live Stats', 'vf-sec-livestats'],
|
|
['Power', 'vf-sec-power'],
|
|
['Manage', 'vf-sec-manage'],
|
|
['Rebuild', 'vf-sec-rebuild'],
|
|
['Reverse DNS', 'vf-sec-rdns'],
|
|
['Resources', 'vf-resources-panel'],
|
|
['Billing & Usage', 'vf-selfservice-panel'],
|
|
['Billing Overview', 'vf-sec-billing'],
|
|
];
|
|
|
|
foreach ($items as $i => $item) {
|
|
$child = $jump->addChild('vfsec-' . $item[1], [
|
|
'label' => $item[0],
|
|
'uri' => '#' . $item[1],
|
|
'order' => ($i + 1) * 10,
|
|
]);
|
|
// data-vf-target lets the smooth-scroll handler in module.js find
|
|
// these links generically (same selector covers both inline and
|
|
// sidebar nav). Class is duplicated for legacy CSS that may key
|
|
// on .vf-nav-link.
|
|
$child->setAttribute('data-vf-target', $item[1]);
|
|
$child->setClass('vf-nav-link');
|
|
}
|
|
} catch (Throwable $e) {
|
|
// Silent failure — sidebar customisation must never break the page.
|
|
}
|
|
});
|