Files
virtfusion-whmcs-module/modules/servers/VirtFusionDirect/hooks.php
Prophet731 27cbe40c52
Some checks failed
Publish Release / release (push) Failing after 16s
chore(release): 1.5.0
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.
2026-04-28 22:07:27 -04:00

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.
}
});