浏览代码

Add fail-closed client backup summary and product-specific option resolution

feature/client-backups-summary
psarria 16 小时前
父节点
当前提交
fe7d9f68dd
共有 18 个文件被更改,包括 1640 次插入5 次删除
  1. +17
    -0
      config.example.php
  2. +48
    -0
      docs/BACKUPS-REGRESSION.md
  3. +55
    -0
      docs/BACKUPS.md
  4. +34
    -0
      lib/BackupAccess.php
  5. +36
    -0
      lib/BackupCache.php
  6. +34
    -0
      lib/BackupHttp.php
  7. +37
    -0
      lib/BackupOption.php
  8. +126
    -0
      lib/BackupParser.php
  9. +128
    -0
      lib/Backups.php
  10. +23
    -0
      templates/backups.php
  11. +1
    -0
      templates/backups.tpl
  12. +34
    -0
      tests/backup-whmcs-mocks.php
  13. +174
    -0
      tests/backups.php
  14. +190
    -0
      tests/fixtures/backups/expected-example.json
  15. +451
    -0
      tests/fixtures/backups/host-detail.example.html
  16. +239
    -0
      tests/fixtures/backups/hosts-summary.example.html
  17. +3
    -3
      tools/audit.py
  18. +10
    -2
      vpsmanager.php

+ 17
- 0
config.example.php 查看文件

@@ -1,5 +1,22 @@
<?php
return [
'backups' => [
'enabled' => false,
// Confirm exact runtime types/values on contracted AND uncontracted services first.
'option_values_confirmed' => false,
'active_values' => ['Backup diario' => [], 'Backup diario VPS' => []], // Strict PHP identity (===), no truthiness or coercion.
'endpoint' => '', // Fixed HTTPS CGI path without query, fragment or embedded credentials.
'username' => '', // HTTP Basic over verified TLS; confirm account permissions.
'password' => '',
'source_timezone' => '', // Verified IANA identifier, never inferred.
'connect_timeout_seconds' => 2,
'timeout_seconds' => 5,
'max_response_bytes' => 2097152,
'cache_directory' => '', // Existing private directory, mode 0700, outside web roots/aliases.
'cache_ttl_seconds' => 120,
'stale_max_age_seconds' => 900,
'max_visible_copies' => 5,
],
'vpsmanager_api_url' => '',
'graphite_url' => '',
'graphite_auth' => 'none', // Set to basic when the Graphite HTTPS proxy requires it.


+ 48
- 0
docs/BACKUPS-REGRESSION.md 查看文件

@@ -0,0 +1,48 @@
# Informe de regresión — 19/09/2026

Implementación local, desactivada. Falta observar la contratación real; este informe no es una aceptación de producción.

## Base y cambios

Base local y HEAD remoto consultado: `1174c120dec0982b4c628a353c66cdaeab894b1e` (`main`, limpio). Worktree separado en rama `feature/client-backups-summary`; el checkout original permanece en `main`. Sin push, merge ni despliegue.

Archivos de runtime añadidos: `lib/BackupAccess.php`, `BackupOption.php`, `BackupCache.php`, `BackupHttp.php`, `BackupParser.php`, `Backups.php`, `templates/backups.php`, `templates/backups.tpl`. Cambios de integración: require del componente, parámetro opcional en registro de botones, botón condicional y callback Backups; nueva sección privada en `config.example.php`. Pruebas y fixtures saneados bajo `tests/`; documentación bajo `docs/`. El auditor extiende a `tests/backups.php` las excepciones existentes para sentinelas `FAKE_` y URLs inválidas de pruebas con dominio `example.invalid`; no se han eximido archivos de runtime.

La opción configurable del módulo, sus posiciones y las implementaciones existentes de provisioning, suspensión/reactivación, panel, Graphite, snapshots, firewall y reinicio/parada/arranque no se modifican. Los cinco botones anteriores siguen registrados incluso sin parámetros/configuración. Las dependencias nuevas fallan de forma aislada/cerrada para copias.

## Verificaciones ejecutadas

Runtime: PHP CLI 8.1.2-1ubuntu2.26, DOM/libxml del paquete Ubuntu `php8.1-xml`, Node.js v22.11.0. PHP no estaba en PATH; se reutilizó el binario local de la entrega anterior y se extrajo DOM bajo `work/`, sin instalar paquetes del sistema. Las suites PHP usan `-n`, con DOM cargado expresamente para backups, sin cURL real. Los comandos siguientes usan `php` como abreviatura de ese runtime.

| Comprobación | Resultado |
|---|---|
| Antes del cambio: `php -n tests/run.php` | PASS: 192 aserciones, HTTP simulado |
| Antes del cambio: `node tests/frontend.cjs` | PASS: 24 aserciones |
| Después: `php -n -d extension=/ruta/dom.so tests/backups.php` | PASS: 192 existentes + 137 nuevas |
| Después: `node tests/frontend.cjs` | PASS: 24 aserciones |
| Diagnóstico separado: `php -n test-diagnostico.php /ruta/checkout` | PASS: 35 aserciones |
| `php -l` en los 22 PHP del checkout y del diagnóstico | PASS |
| `node --check assets/metrics.js` | PASS, JS sin cambios |
| `git diff --check` | PASS |
| `python3 tools/audit.py` | PASS heurístico; revisión manual descrita abajo |

Cobertura nueva ejecutada: claves exactas y grupos aplicables, no herencia de opciones de otros grupos, duplicados ambiguos, conflicto de ambas claves/tipos con incidencia segura, lista estricta sintética, valores ausentes/nulos/desconocidos, host y límites; detalle correcto/equivocado/resumen/login; tablas y filas reordenadas/unión por ID; contadores/tamaños ausentes; cinco/seis/cero filas; Filled, idle y estado desconocido; fechas y DST ambiguo/inexistente; HTTP 200/301/302/401/403/404/500, retorno falso cURL para fallo TLS/timeout y aborto por tamaño; URL fija y política TLS/redirect; propietario/sin sesión/permiso delegado denegado y permitido con mocks; alteración de ID desde callback real; no consultas denegadas; caché aislada por servicio/host/configuración, revocación, TTL, stale y caducidad; escape y sentinelas públicos.

El caso TLS simula el retorno de error de cURL y verifica sus opciones; no realiza un handshake real. El caso de delegado simula el contrato de CurrentUser/permiso; no ejecuta WHMCS. La prueba de integración llama al callback de producción con la frontera de sesión y consultas Capsule simuladas, incluyendo servicio ajeno: no se limita a pasar parámetros que ya se suponen autorizados, pero no ejecuta el router cifrado real.

## Renderizado y privacidad

Se ejecutó `Backups::render` desde el callback y se guardó la evidencia sintética `vista-backups-fixture.html` junto a la entrega. Contiene la vista propia dentro de un contenedor HTML representativo, no el tema completo de WHMCS. Se comprueba ausencia de proveedor, hosts, URL privada, credencial falsa, número interno, enlaces y conteos de ficheros; el contenido malicioso queda escapado. Smarty recibe un único campo HTML propio. No se ha ejecutado Smarty ni una sesión de navegador sobre WHMCS: no se declara validada la página completa real.

Los tres fixtures se copiaron exclusivamente del ZIP saneado proporcionado. Revisión manual: hosts `.invalid`, usuario de prueba e IP documental; referencias públicas al proyecto del proveedor conservadas solo como material de parser. No se han copiado exportaciones originales, capturas de clientes, configuración privada ni credenciales reales. El escáner inicialmente señaló dos sentinelas de pruebas, revisados y cubiertos con las mismas excepciones acotadas que la suite existente. No publicar ni desplegar fixtures bajo una ruta HTTP.

## Pendientes reales — no son PASS

- Los dos IDs de servicio autorizados para diagnóstico y las observaciones de presencia/tipo/valor en contratado y no contratado. Los IDs de definición de opciones y `optiontype=3` no cubren esta verificación.
- Fijar la regla exacta observada, con tests, y retirar el diagnóstico temporal de la instalación si se utiliza. Mientras tanto listas activas vacías y confirmación desactivada.
- WHMCS/tema/Smarty reales: firma efectiva del callback de botones, permiso `products`, CurrentUser, enrutamiento, propietario/delegados e IDOR, formato horario y semántica del campo exclusivamente administrativo, ausencia en emails/pedido/factura/página completa.
- Cuenta del origen, permisos de solo lectura efectivos, mecanismo de autenticación, endpoint, TLS real, zona horaria y estructura de detalle de un host realmente sin copias.
- Caché fuera de todos los alias HTTP, permisos del usuario PHP, exclusión del CDN/caché de página y verificación entre sesiones.
- Regresión operativa en staging: Graphite/panel/snapshots/firewall/reiniciar/detener/iniciar/suspensión/reactivación. El código existente se conserva y sus pruebas simuladas pasan, pero no se han ejecutado órdenes reales ni se garantiza desde este entorno la instalación productiva.

Instalación y rollback: `BACKUPS.md`. Diagnóstico temporal separado y no integrado en runtime: carpeta `diagnostico` de la entrega, con instrucciones para revisión. La implementación no está habilitada para clientes y no se considera terminada la validación de contratación.

+ 55
- 0
docs/BACKUPS.md 查看文件

@@ -0,0 +1,55 @@
# Copias de seguridad: instalación controlada y contrato

Estado: implementación en rama de trabajo, integración cerrada por defecto. No desplegada.
Base: `1174c120dec0982b4c628a353c66cdaeab894b1e`, rama original `main`, árbol original limpio. El HEAD remoto coincidía al consultar `git ls-remote origin HEAD` el 19/09/2026. Rama de trabajo: `feature/client-backups-summary`. No se ha hecho push ni merge.

## Contratación y autorización

Se admiten exclusivamente las claves exactas `Backup diario` y `Backup diario VPS`. Las definiciones comunicadas son Sí/No (`optiontype=3`), pero no se ha observado todavía el tipo PHP/valor de contratación que entrega el módulo. No se han usado ni IDs de opción ni el tipo de definición como indicadores de activación.

`BackupOption::applicable()` resuelve los grupos vinculados al producto del servicio autorizado a través de `tblhosting.packageid` → `tblproductconfiglinks.pid/gid` → `tblproductconfigoptions.gid`. No usa LIKE ni una selección global del primer resultado. Las definiciones duplicadas de una clave o un tipo no confirmado cierran el acceso. Si las dos claves están presentes con valores no idénticos (`!==`), se deniega y se registra `OPTION_CONFLICT` con el ID del servicio.

La regla provisional es cerrada: `enabled` y `option_values_confirmed` deben ser exactamente `true`; cada clave aplicable debe existir mediante `array_key_exists`, contener string, entero o booleano y coincidir con identidad estricta con un elemento de su propia lista `active_values`. Ambas listas se entregan vacías. Las pruebas usan `SYNTHETIC_ACTIVE`, nunca como supuesto valor real. Las claves ausentes, `null`, arrays, floats y textos desconocidos no conceden acceso. Se fijará la regla observada y sus tests tras el diagnóstico administrativo separado; no extrapolar automáticamente entre nombres sin evidencia.

La entrada es una función de módulo invocada por WHMCS. El registro `ClientAreaCustomButtonArray` conserva los cinco botones existentes y añade Backups solo si es elegible. La función `vpsmanager_Backups` vuelve a comprobarlo. Antes de caché/HTTP, `BackupAccess` comprueba CurrentUser, cuenta de cliente, permiso `products`, y pertenencia del servicio en la base de datos. No confunde el usuario delegado con el ID de la cuenta cliente. Versiones sin estas interfaces fallan de forma cerrada. La autorización efectiva del router WHMCS y sus accesos delegados debe comprobarse en staging de la versión instalada.

## Instalación, sin ejecución automática

1. Respaldar el directorio actual del módulo y la configuración privada, conservando permisos. Registrar el commit instalado y los checksums. No usar operaciones reales sobre VPS/copias como pruebas de regresión.
2. Revisar el diff de la rama frente al commit base. Requisitos: PHP compatible con el módulo (probado 8.1.2), extensiones DOM/libxml y cURL. No instalar tests, fixtures, documentación, herramientas ni `.git` bajo el directorio web. Copiar únicamente el módulo y sus dependencias runtime en una ventana controlada, con la integración desactivada.
3. Ampliar el archivo privado que ya carga `Config::load()` (variable `O6H_VPSMANAGER_CONFIG` o `/etc/open6hosting/whmcs-vpsmanager.php`) con la sección `backups` de `config.example.php`, manteniendo intactas las opciones de Graphite/API. Permisos ajustados al usuario PHP; nunca guardar credenciales en el repo o en URLs/comandos.
4. Configurar el endpoint HTTPS fijo del CGI, sin query/fragmento/credenciales embebidas, y autenticación HTTP Basic sobre TLS verificado. Si la instalación usa otro mecanismo, no activar hasta adaptar/probarlo. Confirmar permisos efectivos de la cuenta: no ser administrador no demuestra solo lectura. Si la interfaz concede acciones, restringir adicionalmente el acceso de integración a la ruta y consulta de host previstas mediante un proxy/política revisados; no se ha instalado tal restricción.
5. Confirmar la zona horaria IANA del origen. Crear un directorio de caché privado, modo 0700, accesible al usuario PHP y fuera de todas las raíces/alias web; configurar `cache_directory`. Los archivos usan 0600. Directorios públicos o permisivos se rechazan; un error de caché no impide la consulta, pero registra `CACHE` y elimina la ventaja de caché entre peticiones. Programar limpieza administrativa de JSON antiguos en ese directorio si cambian muchos servicios/mapeos: no se incluye un cron automático.
6. Por producto, comprobar si ya existe `backuppc_host`. Crear solo si falta, sin duplicados ni migraciones de dominio: texto, exclusivamente administrativo, no pedido, no factura, no campo de comunicaciones. Guardar un mapeo explícito. El código exige `adminonly='on'`, `showorder=''`, `showinvoice=''`, tipo `text`; confirmar estos valores de esquema en la versión instalada. No modificar campos automáticamente. La plantilla completa y los emails pueden tener personalizaciones: verificar que nunca exponen el nombre ni el valor.
7. Ejecutar el diagnóstico temporal administrativo revisado para un servicio contratado y otro no contratado, limitado a sus dos IDs. Retirarlo al obtener resultados. Registrar solo clave, presencia, tipo y valor; completar las listas exactas y sus tests antes de confirmar/activar la integración.
8. En staging, comprobar contratado/no contratado, servicio ajeno cambiando ID, sin sesión, delegado permitido y denegado, revocación, cambio de mapeo y conflicto de nombres. Comprobar la página completa, DOM, HTML, emails y solicitudes del navegador: ninguna referencia al proveedor, host o credenciales; solo rutas WHMCS para esta función.
9. Probar fallo del backend y aislamiento respecto a los controles existentes. Desactivar cualquier caché HTTP/CDN de páginas privadas y confirmar que nunca comparte respuestas entre cuentas. El callback añade `Cache-Control: private, no-store`.
10. Activar únicamente tras superar esas comprobaciones y la revisión operativa. No iniciar, borrar, parar, descargar ni restaurar copias para validar esta lectura.

## Datos y comportamiento

Una sola petición a `endpoint?host=...`, exclusivamente el host administrativo validado con `\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z`. No se siguen redirecciones, enlaces ni formularios. TLS verificado, límite por fragmento recibido de 2 MiB, conexión 2 s y total 5 s por defecto. Códigos HTTP distintos de 200 fallan. No hay endpoint público nuevo ni consultas del navegador al origen.

DOM/libxml analiza sin resolución externa y con `LIBXML_NONET`. La identidad se exige en el único h1 exacto del detalle. Las tablas se reconocen por cabeceras; tamaños exigen el grupo Totals; la unión es por número interno. Se rechazan duplicados. Las filas y tablas pueden reordenarse. El número de copia, host, conteo de ficheros, HTML, enlaces y configuración no llegan a la plantilla.

El contrato público contiene disponibilidad, actualización, indicación de datos antiguos, zona, actividad reconocida, total/desglose, inicio de última completa, antigüedad en horas y hasta cinco filas con inicio/tipo/duración/tamaño/incidencias. Todas las variables se escapan en HTML. La plantilla Smarty recibe únicamente HTML propio ya escapado y reconstruido; no HTML del origen.

Las fechas son inicios, nunca finales calculados. Se normalizan a timestamps con zona explícita; minutos inexistentes o ambiguos por cambio horario fallan de forma cerrada porque el HTML no incluye offset. El formato presentado es `d/m/Y H:i T`, con zona IANA visible; validar coherencia con el tema/locale instalado. MiB/1024 son GiB lógicos aproximados, no ocupación ni transferencia. Filled no modifica incidencias. Cuatro contadores presentes a cero significan «Sin incidencias registradas»; alguno positivo, «Con incidencias»; faltantes, «Información no disponible». No se suma un total de errores. Idle significa solo ausencia de actividad; estados desconocidos se omiten. No hay alarmas SLA ni promesas de restauración.

Una tabla reconocida con cabecera completa y sin filas se trata como cero retenido (caso sintético probado); la ausencia de tabla es error, nunca cero. No se han aportado ejemplos reales de detalle sin copias: cualquier otra estructura fallará cerrada hasta disponer de evidencia.

La caché contiene datos normalizados sin credenciales ni host. Su clave es SHA-256 de versión/configuración/servicio/host. Autorización y contratación se repiten antes de leerla. TTL 120 s; ante fallo, datos antiguos hasta 900 s desde su obtención, con aviso y fecha originales. Caducados: indisponibilidad. Cambios de mapeo/destino/configuración no reutilizan entradas anteriores. Logs administrativos: solo códigos fijos y servicio; no excepciones ni respuestas del origen.

## Rollback

Desactivar `backups.enabled` inmediatamente en la configuración privada. Restaurar el módulo respaldado o distribuir el archivo runtime del commit base `1174c120dec0982b4c628a353c66cdaeab894b1e` desde un checkout de preparación. No hacer reset destructivo en un árbol compartido. Limpiar la caché privada de esta integración y la caché de plantillas WHMCS según el procedimiento de la instalación. Retirar únicamente la sección de configuración nueva si se desea; conservar Graphite/API. No hay migraciones ni cambios en contratación que revertir. Puede conservarse el campo administrativo desactivado/documentado, o retirarlo solo tras respaldar sus valores y aprobarlo. Retirar el diagnóstico temporal y restaurar la función administrativa si sigue instalado.

Comprobar que desaparece Backups y que panel/gráficas y botones existentes conservan comportamiento. No confundir un rollback de código con revertir contratos.

## Referencias de integración consultadas

- https://developers.whmcs.com/provisioning-modules/custom-functions
- https://developers.whmcs.com/advanced/authentication
- https://developers.whmcs.com/classes/whmcs/user/client

Estas referencias no sustituyen la validación de la versión y tema instalados.

+ 34
- 0
lib/BackupAccess.php 查看文件

@@ -0,0 +1,34 @@
<?php
namespace VPSManager;
require_once __DIR__ . '/BackupOption.php';

final class BackupAccess
{
// Invoked only by WHMCS module callbacks, never a standalone public endpoint.
// No request service ID, client ID or host is trusted here.
public static function context(array $params): ?array
{
try {
$id = filter_var($params['serviceid'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if (!$id || !class_exists('WHMCS\\Authentication\\CurrentUser')) return null;
$current = new \WHMCS\Authentication\CurrentUser();
$client = $current->client();
if (!$client || !$current->isAuthenticatedUser() || !$client->hasPermission('products')) return null;
$service = \WHMCS\Database\Capsule::table('tblhosting')->where('id', $id)->where('userid', $client->id)->first();
if (!$service) return null;
try { $keys = BackupOption::applicable($service->packageid); }
catch (\Throwable $e) { Backups::diagnostic('OPTION_DEFINITION', $id); return null; }
$fields = \WHMCS\Database\Capsule::table('tblcustomfields')
->where('type', 'product')->where('relid', $service->packageid)
->where('fieldname', 'backuppc_host')->get();
if (count($fields) !== 1) return ['serviceid' => $id, 'option_keys' => $keys, 'host' => null];
$field = $fields[0];
if ($field->fieldtype !== 'text' || $field->adminonly !== 'on'
|| $field->showorder !== '' || $field->showinvoice !== '') return ['serviceid' => $id, 'option_keys' => $keys, 'host' => null];
$values = \WHMCS\Database\Capsule::table('tblcustomfieldsvalues')->where('fieldid', $field->id)->where('relid', $id)->get();
return ['serviceid' => $id, 'option_keys' => $keys, 'host' => count($values) === 1 ? $values[0]->value : null];
} catch (\Throwable $e) {
return null; // Unsupported WHMCS authentication/schema fails closed.
}
}
}

+ 36
- 0
lib/BackupCache.php 查看文件

@@ -0,0 +1,36 @@
<?php
namespace VPSManager;

final class BackupCache
{
private $directory;
public function __construct(string $directory)
{
$real = realpath($directory);
if (!$real || !is_dir($real) || is_link($directory) || (fileperms($real) & 0077) !== 0
|| !is_writable($real)) throw new \RuntimeException('CACHE_CONFIG');
foreach ([dirname(__DIR__), $_SERVER['DOCUMENT_ROOT'] ?? ''] as $public) {
$root = $public === '' ? false : realpath($public);
if ($root && ($real === $root || strpos($real . '/', $root . '/') === 0)) throw new \RuntimeException('CACHE_PUBLIC');
}
// Administrator must choose a directory outside every web alias as well.
$this->directory = $real;
}
public function read(string $key): ?array
{
$file = $this->directory . '/' . $key . '.json';
if (!is_file($file) || is_link($file) || filesize($file) > 2097152) return null;
$value = json_decode(file_get_contents($file), true, 32);
return is_array($value) ? $value : null;
}
public function write(string $key, array $data): void
{
$file = tempnam($this->directory, '.new-');
if ($file === false) throw new \RuntimeException('CACHE_WRITE');
try {
chmod($file, 0600);
if (file_put_contents($file, json_encode($data, JSON_THROW_ON_ERROR), LOCK_EX) === false
|| !rename($file, $this->directory . '/' . $key . '.json')) throw new \RuntimeException('CACHE_WRITE');
} finally { if (is_file($file)) unlink($file); }
}
}

+ 34
- 0
lib/BackupHttp.php 查看文件

@@ -0,0 +1,34 @@
<?php
namespace VPSManager;
require_once __DIR__ . '/Config.php';

final class BackupHttp
{
public static function request(array $config, string $host): string
{
if (!Backups::validHost($host) || !function_exists('curl_init')) throw new \RuntimeException('TRANSPORT');
$endpoint = Config::url($config, 'endpoint');
$user = $config['username'] ?? null; $password = $config['password'] ?? null;
if (!is_string($user) || !is_string($password) || $user === '' || $password === ''
|| strpos($user, ':') !== false || preg_match('/[\x00-\x1f\x7f]/', $user . $password)) throw new \RuntimeException('AUTH_CONFIG');
$url = $endpoint . '?' . http_build_query(['host' => $host], '', '&', PHP_QUERY_RFC3986);
$ch = curl_init($url); $body = '';
if ($ch === false) throw new \RuntimeException('TRANSPORT');
try {
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => Backups::limit($config, 'connect_timeout_seconds', 2, 1, 10),
CURLOPT_TIMEOUT => Backups::limit($config, 'timeout_seconds', 5, 1, 30),
CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_USERNAME => $user, CURLOPT_PASSWORD => $password,
CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use (&$body, $config): int {
if (strlen($body) + strlen($chunk) > Backups::limit($config, 'max_response_bytes', 2097152, 1024, 2097152)) return 0;
$body .= $chunk;
return strlen($chunk);
},
]);
if (curl_exec($ch) === false || curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) throw new \RuntimeException('HTTP');
return $body;
} finally { curl_close($ch); }
}
}

+ 37
- 0
lib/BackupOption.php 查看文件

@@ -0,0 +1,37 @@
<?php
namespace VPSManager;

final class BackupOption
{
public const NAMES = ['Backup diario', 'Backup diario VPS'];

// Resolve only groups linked to the product of the authorized service.
// Definitions describe applicable options; they never prove subscription.
public static function applicable($productId): array
{
$groups = \WHMCS\Database\Capsule::table('tblproductconfiglinks')->where('pid', $productId)->get();
$seenGroups = []; $keys = [];
foreach ($groups as $link) {
if (isset($seenGroups[$link->gid])) continue;
$seenGroups[$link->gid] = true;
$options = \WHMCS\Database\Capsule::table('tblproductconfigoptions')->where('gid', $link->gid)->get();
foreach ($options as $option) {
if (!in_array($option->optionname, self::NAMES, true)) continue;
// Only the confirmed Yes/No definition type is supported. This is NOT activation.
if (!in_array($option->optiontype, [3, '3'], true) || in_array($option->optionname, $keys, true)) {
throw new \RuntimeException('OPTION_DEFINITION');
}
$keys[] = $option->optionname;
}
}
sort($keys, SORT_STRING);
return $keys;
}

public static function conflict(array $options): bool
{
return array_key_exists(self::NAMES[0], $options) && array_key_exists(self::NAMES[1], $options)
&& $options[self::NAMES[0]] !== $options[self::NAMES[1]];
}

}

+ 126
- 0
lib/BackupParser.php 查看文件

@@ -0,0 +1,126 @@
<?php
namespace VPSManager;

final class BackupParser
{
private static function text(\DOMNode $node): string
{
return trim(preg_replace('/\s+/u', ' ', $node->textContent));
}

public static function number(?string $value): ?float
{
if ($value === null || !preg_match('/\A[0-9]+(?:\.[0-9]+)?\z/', $value)) return null;
$n = (float) $value;
return is_finite($n) ? $n : null;
}

public static function timestamp(string $value, \DateTimeZone $zone): int
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $value, $zone);
if (!$date || $date->format('Y-m-d H:i') !== $value) throw new \RuntimeException('DATE');
// A repeated local minute cannot be assigned an offset from these HTML exports.
$stamp = $date->getTimestamp();
$transitions = $zone->getTransitions($stamp - 86400, $stamp + 86400);
foreach ($transitions ?: [] as $transition) {
$candidate = $stamp + $date->getOffset() - $transition['offset'];
if ($candidate !== $stamp && (new \DateTimeImmutable('@' . $candidate))->setTimezone($zone)->format('Y-m-d H:i') === $value) {
throw new \RuntimeException('DATE_AMBIGUOUS');
}
}
return $stamp;
}

public static function parse(string $html, string $host, string $timezone): array
{
if (!Backups::validHost($host) || !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) throw new \RuntimeException('CONFIG');
$zone = new \DateTimeZone($timezone);
if (!class_exists('DOMDocument')) throw new \RuntimeException('DOM');
$previous = libxml_use_internal_errors(true);
try {
$doc = new \DOMDocument();
$doc->resolveExternals = false;
$doc->substituteEntities = false;
if (!$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) throw new \RuntimeException('HTML');
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$xp = new \DOMXPath($doc);
$headings = $xp->query('//h1');
if ($headings->length !== 1 || self::text($headings->item(0)) !== 'Host ' . $host . ' Backup Summary') throw new \RuntimeException('IDENTITY');
$sets = [];
foreach ($xp->query('//table') as $table) {
$headers = null; $kind = null; $rows = [];
foreach ($xp->query('./tr|./thead/tr|./tbody/tr', $table) as $row) {
$cells = [];
foreach ($xp->query('./td|./th', $row) as $cell) $cells[] = self::text($cell);
if (($cells[0] ?? '') === 'Backup#') {
if ($headers !== null) throw new \RuntimeException('DUPLICATE_HEADER');
$headers = $cells;
if (in_array('Start Date', $cells, true) && in_array('Duration/mins', $cells, true)) $kind = 'copies';
elseif (in_array('#Xfer errs', $cells, true)) $kind = 'errors';
elseif (array_slice($cells, 0, 5) === ['Backup#', 'Type', '#Files', 'Size/MiB', 'MiB/sec']) {
// Verify that the first size column actually belongs to Totals.
$first = $xp->query('./tr|./thead/tr|./tbody/tr', $table)->item(0);
$groups = $xp->query('./td|./th', $first);
if ($groups->length >= 2 && self::text($groups->item(1)) === 'Totals'
&& $groups->item(0)->getAttribute('colspan') === '2'
&& $groups->item(1)->getAttribute('colspan') === '3') $kind = 'sizes';
}
continue;
}
if ($kind === null) continue;
if (!preg_match('/\A[0-9]+\z/', $cells[0] ?? '')) throw new \RuntimeException('ROW');
$id = $cells[0];
if (isset($rows[$id])) throw new \RuntimeException('DUPLICATE');
if (count($cells) !== count($headers)) {
if ($kind === 'copies') throw new \RuntimeException('ROW_WIDTH');
$rows[$id] = [];
continue;
}
$data = [];
foreach ($headers as $i => $name) if (!array_key_exists($name, $data)) $data[$name] = $cells[$i] ?? null;
$rows[$id] = $data;
}
if ($kind !== null) {
if (isset($sets[$kind])) throw new \RuntimeException('TABLE_DUPLICATE');
$sets[$kind] = $rows;
}
}
if (!isset($sets['copies'])) throw new \RuntimeException('MISSING_TABLE');
// Recognized empty summary table is explicit evidence of zero retained rows.
$copies = []; $full = 0; $incremental = 0;
foreach ($sets['copies'] as $id => $row) {
$type = ['full' => 'Completa', 'incr' => 'Incremental'][$row['Type'] ?? ''] ?? 'Información no disponible';
if ($type === 'Completa') $full++;
if ($type === 'Incremental') $incremental++;
$errors = $sets['errors'][$id] ?? [];
$counters = [];
foreach (['#Xfer errs', '#bad files', '#bad share', '#tar errs'] as $key) {
$v = $errors[$key] ?? null;
$counters[] = is_string($v) && preg_match('/\A[0-9]+\z/', $v) ? self::number($v) : null;
}
$incidents = 'Información no disponible';
if (count(array_filter($counters, static fn($v) => $v !== null && $v > 0))) $incidents = 'Con incidencias';
elseif (!in_array(null, $counters, true)) $incidents = 'Sin incidencias registradas';
$size = self::number($sets['sizes'][$id]['Size/MiB'] ?? null);
$copies[] = [
'start' => self::timestamp($row['Start Date'] ?? '', $zone),
'type' => $type,
'duration_minutes' => self::number($row['Duration/mins'] ?? null),
'size_gib' => $size === null ? null : $size / 1024,
'incidents' => $incidents,
];
}
usort($copies, static fn($a, $b) => $b['start'] <=> $a['start']);
$lastFull = null;
foreach ($copies as $copy) if ($copy['type'] === 'Completa') { $lastFull = $copy['start']; break; }
$activity = null;
foreach ($xp->query('//li') as $li) {
if (preg_match('/\ALast status is state "idle" \(done\) as of [0-9 :.-]+\.\z/', self::text($li))) $activity = 'Sin actividad en curso';
}
return ['total' => count($copies), 'full' => $full, 'incremental' => $incremental,
'last_full' => $lastFull, 'activity' => $activity, 'timezone' => $timezone, 'copies' => $copies];
}
}

+ 128
- 0
lib/Backups.php 查看文件

@@ -0,0 +1,128 @@
<?php
namespace VPSManager;
require_once __DIR__ . '/BackupAccess.php';
require_once __DIR__ . '/BackupParser.php';
require_once __DIR__ . '/BackupHttp.php';
require_once __DIR__ . '/BackupCache.php';

final class Backups
{
public static function validHost($value): bool
{
return is_string($value) && preg_match('/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/', $value) === 1;
}
public static function limit(array $config, string $key, int $default, int $min, int $max): int
{
$value = $config[$key] ?? $default;
if (!is_int($value) || $value < $min || $value > $max) throw new \RuntimeException('CONFIG_LIMIT');
return $value;
}
public static function entitled(array $params, array $config, array $keys): bool
{
$options = is_array($params['configoptions'] ?? null) ? $params['configoptions'] : [];
if (BackupOption::conflict($options)) {
self::diagnostic('OPTION_CONFLICT', (int) ($params['serviceid'] ?? 0));
return false;
}
if (($config['enabled'] ?? false) !== true || ($config['option_values_confirmed'] ?? false) !== true || !$keys) return false;
foreach ($keys as $key) {
if (!in_array($key, BackupOption::NAMES, true) || !array_key_exists($key, $options)) return false;
$value = $options[$key];
if (!is_string($value) && !is_int($value) && !is_bool($value)) return false;
$allowed = $config['active_values'][$key] ?? [];
if (!is_array($allowed) || !in_array($value, $allowed, true)) return false;
}
return true;
}
public static function diagnostic(string $code, int $service): void
{
// Codes are chosen by this module, never exception messages or backend content.
if (function_exists('logActivity')) logActivity('VPSManager copies ' . $code . ' service=' . $service);
}
public static function button(array $params): bool
{
try {
$context = BackupAccess::context($params);
$config = Config::load()['backups'] ?? [];
if (!$context || !self::entitled($params, $config, $context['option_keys'])) return false;
if (!self::validHost($context['host'])) {
self::diagnostic('MAPPING', $context['serviceid']);
return false;
}
return true;
} catch (\Throwable $e) { return false; }
}
public static function page(array $params): array
{
$view = ['available' => false];
try {
// The same authorization and entitlement precede both button and page.
$context = BackupAccess::context($params);
$config = Config::load()['backups'] ?? [];
if ($context && self::entitled($params, $config, $context['option_keys'])) {
if (self::validHost($context['host'])) $view = self::summary($context, $config);
else self::diagnostic('MAPPING', $context['serviceid']);
}
} catch (\Throwable $e) { /* Generic public response only. */ }
if (!headers_sent()) header('Cache-Control: private, no-store, max-age=0');
return ['templatefile' => 'templates/backups', 'vars' => ['copiesHtml' => self::render($view)]];
}
// Internal service: callers must authorize each request before entering this method.
public static function summary(array $context, array $config, ?callable $transport = null, ?int $now = null): array
{
$now = $now ?? time();
if (!self::validHost($context['host'] ?? null)) return ['available' => false];
$cache = null; $entry = null;
// Configuration hash invalidates data on any policy, destination or credential change.
// Only a digest is stored in the filename; no configuration or secrets in the value.
$key = hash('sha256', serialize(['v1', $config, $context['serviceid'], $context['host']]));
$ttl = self::limit($config, 'cache_ttl_seconds', 120, 0, 900);
$stale = self::limit($config, 'stale_max_age_seconds', 900, 0, 900);
try {
$cache = new BackupCache($config['cache_directory'] ?? '');
$entry = $cache->read($key);
} catch (\Throwable $e) { self::diagnostic('CACHE', $context['serviceid']); }
$age = is_int($entry['obtained_at'] ?? null) ? $now - $entry['obtained_at'] : PHP_INT_MAX;
if ($age >= 0 && $age < min($ttl, $stale) && is_array($entry['data'] ?? null)) return self::publicView($entry, false, $config, $now);
try {
$body = $transport ? $transport($config, $context['host']) : BackupHttp::request($config, $context['host']);
if (!is_string($body) || strlen($body) > self::limit($config, 'max_response_bytes', 2097152, 1024, 2097152)) throw new \RuntimeException('SIZE');
$data = BackupParser::parse($body, $context['host'], $config['source_timezone'] ?? '');
$entry = ['obtained_at' => $now, 'data' => $data];
if ($cache) {
try { $cache->write($key, $entry); } catch (\Throwable $e) { self::diagnostic('CACHE', $context['serviceid']); }
}
return self::publicView($entry, false, $config, $now);
} catch (\Throwable $e) {
self::diagnostic('SOURCE', $context['serviceid']);
if ($age >= 0 && $age <= $stale && is_array($entry['data'] ?? null)) return self::publicView($entry, true, $config, $now);
return ['available' => false];
}
}
private static function publicView(array $entry, bool $stale, array $config, int $now): array
{
$data = $entry['data'];
$zone = new \DateTimeZone($data['timezone']);
$date = static fn($ts) => $ts === null ? 'Información no disponible' : (new \DateTimeImmutable('@' . $ts))->setTimezone($zone)->format('d/m/Y H:i T');
$rows = [];
foreach (array_slice($data['copies'], 0, self::limit($config, 'max_visible_copies', 5, 1, 50)) as $copy) {
$rows[] = ['start' => $date($copy['start']), 'type' => $copy['type'],
'duration' => $copy['duration_minutes'] === null ? 'Información no disponible' : number_format($copy['duration_minutes'], 1, ',', '.') . ' min',
'size' => $copy['size_gib'] === null ? 'Información no disponible' : number_format($copy['size_gib'], 2, ',', '.') . ' GiB',
'incidents' => $copy['incidents']];
}
return ['available' => true, 'stale' => $stale, 'updated' => $date($entry['obtained_at']),
'timezone' => $data['timezone'], 'activity' => $data['activity'],
'total' => $data['total'], 'full' => $data['full'], 'incremental' => $data['incremental'],
'last_full' => $date($data['last_full']), 'rows' => $rows,
'age' => isset($data['copies'][0]) ? max(0, (int) floor(($now - $data['copies'][0]['start']) / 3600)) : null];
}
public static function render(array $view): string
{
$escape = static fn($value) => htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
ob_start();
// Isolated scope: the template receives only public allowlisted presentation data.
require __DIR__ . '/../templates/backups.php';
return ob_get_clean();
}
}

+ 23
- 0
templates/backups.php 查看文件

@@ -0,0 +1,23 @@
<?php if (!defined('WHMCS')) { http_response_code(403); exit; } ?>
<section aria-label="Copias de seguridad">
<h2>Copias de seguridad</h2>
<?php if (empty($view['available'])): ?>
<p>La información de las copias de seguridad no está disponible temporalmente.</p>
<?php else: ?>
<?php if ($view['stale']): ?><p role="status">No se ha podido actualizar la información. Se muestran los últimos datos disponibles.</p><?php endif; ?>
<p>Datos obtenidos: <?= $escape($view['updated']) ?>. Zona horaria: <?= $escape($view['timezone']) ?>.</p>
<?php if ($view['activity'] !== null): ?><p>Actividad actual: <?= $escape($view['activity']) ?>.</p><?php endif; ?>
<p>Copias disponibles: <?= $escape($view['total']) ?> — <?= $escape($view['full']) ?> completas y <?= $escape($view['incremental']) ?> incrementales.</p>
<?php if ($view['total'] === 0): ?>
<p>Todavía no hay copias disponibles.</p>
<?php else: ?>
<p>Inicio de la última copia: <?= $escape($view['rows'][0]['start']) ?>. Tipo: <?= $escape($view['rows'][0]['type']) ?>.</p>
<p>Antigüedad desde el inicio: <?= $escape($view['age']) ?> horas. Incidencias: <?= $escape($view['rows'][0]['incidents']) ?>.</p>
<p>Inicio de la última completa: <?= $escape($view['last_full']) ?>.</p>
<h3>Últimas copias disponibles</h3>
<div class="table-responsive"><table class="table"><thead><tr><th scope="col">Inicio</th><th scope="col">Tipo</th><th scope="col">Duración</th><th scope="col">Tamaño de datos</th><th scope="col">Incidencias</th></tr></thead><tbody>
<?php foreach ($view['rows'] as $row): ?><tr><?php foreach (['start', 'type', 'duration', 'size', 'incidents'] as $column): ?><td><?= $escape($row[$column]) ?></td><?php endforeach; ?></tr><?php endforeach; ?>
</tbody></table></div>
<p>Las fechas indican el inicio. Los tamaños son datos lógicos aproximados. La lista muestra copias retenidas, no todos los intentos ni una verificación de restauración.</p>
<?php endif; endif; ?>
</section>

+ 1
- 0
templates/backups.tpl 查看文件

@@ -0,0 +1 @@
{$copiesHtml}

+ 34
- 0
tests/backup-whmcs-mocks.php 查看文件

@@ -0,0 +1,34 @@
<?php
// Representative WHMCS boundary only: not an implementation of the encrypted router.
namespace WHMCS\Authentication {
class CurrentUser {
public function isAuthenticatedAdmin() { return $GLOBALS['isAdmin'] ?? false; }
public function client() { return $GLOBALS['actor'] ?? null; }
public function isAuthenticatedUser() { return isset($GLOBALS['actor']); }
}
}
namespace WHMCS\Database {
class Capsule {
public static function table($table) { return new Query($table); }
}
class Query {
private $table; private $where = [];
public function __construct($table) { $this->table = $table; }
public function where($key, $value) { $this->where[$key] = $value; return $this; }
public function get() {
return array_values(array_filter($GLOBALS['db'][$this->table] ?? [], function ($row) {
foreach ($this->where as $key => $value) if ($row->$key != $value) return false;
return true;
}));
}
public function first() { return $this->get()[0] ?? null; }
}
}
namespace {
class BackupTestClient {
public $id; public $permitted;
public function __construct($id, $permitted = true) { $this->id = $id; $this->permitted = $permitted; }
public function hasPermission($permission) { return $permission === 'products' && $this->permitted; }
}
function logActivity($message) { $GLOBALS['safeLogs'][] = $message; }
}

+ 174
- 0
tests/backups.php 查看文件

@@ -0,0 +1,174 @@
<?php
require __DIR__ . '/backup-whmcs-mocks.php';
require __DIR__ . '/run.php'; // Existing regression + cURL mock, no real traffic.
use VPSManager\Backups;
use VPSManager\BackupParser;
use VPSManager\BackupHttp;
use VPSManager\BackupAccess;
use VPSManager\BackupCache;
$before = $count;
$html = file_get_contents(__DIR__ . '/fixtures/backups/host-detail.example.html');
$host = 'fixture-target.invalid';
$expected = json_decode(file_get_contents(__DIR__ . '/fixtures/backups/expected-example.json'), true);
$dir = sys_get_temp_dir() . '/copies-test-' . bin2hex(random_bytes(8));
mkdir($dir, 0700);
$temp = tempnam(sys_get_temp_dir(), 'copies-config-');
putenv('O6H_VPSMANAGER_CONFIG=' . $temp);
$config = ['enabled' => true, 'option_values_confirmed' => true, 'active_values' => ['Backup diario' => ['SYNTHETIC_ACTIVE'], 'Backup diario VPS' => ['SYNTHETIC_ACTIVE']],
'endpoint' => 'https://PRIVATE-ENDPOINT.invalid/cgi', 'username' => 'FAKE_USER', 'password' => 'FAKE_SECRET_SENTINEL',
'source_timezone' => 'Europe/Madrid', 'cache_directory' => $dir];
$params = ['serviceid' => 42, 'configoptions' => ['Backup diario' => 'SYNTHETIC_ACTIVE'],
'customfields' => ['backuppc_host' => 'UNTRUSTED_PARAMS.invalid']];
$GLOBALS['actor'] = new BackupTestClient(7);
$GLOBALS['db'] = [
'tblproductconfiglinks' => [(object) ['pid' => 3, 'gid' => 5]],
'tblproductconfigoptions' => [(object) ['id' => 7, 'gid' => 5, 'optionname' => 'Backup diario', 'optiontype' => 3]],
'tblhosting' => [(object) ['id' => 42, 'userid' => 7, 'packageid' => 3], (object) ['id' => 43, 'userid' => 8, 'packageid' => 3]],
'tblcustomfields' => [(object) ['id' => 6, 'type' => 'product', 'relid' => 3, 'fieldname' => 'backuppc_host', 'fieldtype' => 'text', 'adminonly' => 'on', 'showorder' => '', 'showinvoice' => '']],
'tblcustomfieldsvalues' => [(object) ['fieldid' => 6, 'relid' => 42, 'value' => $host], (object) ['fieldid' => 6, 'relid' => 43, 'value' => 'OTHER_CUSTOMER.invalid']],
];
function altered(string $html, callable $edit): string {
$d = new DOMDocument(); $previous = libxml_use_internal_errors(true);
$d->loadHTML($html, LIBXML_NONET); libxml_clear_errors(); libxml_use_internal_errors($previous);
$edit(new DOMXPath($d), $d); return $d->saveHTML();
}
function parseFixture($html) { return BackupParser::parse($html, 'fixture-target.invalid', 'Europe/Madrid'); }
function tableWith(DOMXPath $xp, $header) {
foreach ($xp->query('//table') as $t) if (strpos($t->textContent, $header) !== false) return $t;
throw new RuntimeException('Missing test table');
}
try {
writeConfig($temp, ['backups' => $config]);
check(Backups::entitled($params, $config, ['Backup diario']), 'explicit synthetic active string');
foreach ([null, false, true, 0, 1, 1.0, '1', 'on', 'yes', 'No', [], new stdClass(), 'unknown'] as $value) {
$p = $params; $p['configoptions']['Backup diario'] = $value;
check(!Backups::entitled($p, $config, ['Backup diario']), 'unknown type/value denied');
}
check(!Backups::entitled([], $config, ['Backup diario']), 'missing option');
check(!Backups::entitled($params, [], ['Backup diario']), 'default closed');
check(!Backups::entitled($params, array_replace($config, ['option_values_confirmed' => false]), ['Backup diario']), 'confirmation gate');
foreach (['', '.', '..', '/etc', 'abc/def', 'a%2fb', 'a?host=b', 'a&action=x', "a\n", ' a', str_repeat('a', 65), [], null] as $value) check(!Backups::validHost($value), 'invalid host');
foreach ([$host, 'a', 'a_B-9.example', str_repeat('a', 64)] as $value) check(Backups::validHost($value), 'valid host');
// Exact names and product/group resolution: no global LIKE or first-row choice.
$originalOptions = $GLOBALS['db']['tblproductconfigoptions'];
$GLOBALS['db']['tblproductconfigoptions'][] = (object) ['id' => 5, 'gid' => 3, 'optionname' => 'Backup diario', 'optiontype' => 3];
check(\VPSManager\BackupOption::applicable(3) === ['Backup diario'], 'unrelated group ignored');
check(\VPSManager\BackupOption::applicable(999) === [], 'no linked group');
$GLOBALS['db']['tblproductconfigoptions'][0]->optionname = 'Backup diario VPS';
check(\VPSManager\BackupOption::applicable(3) === ['Backup diario VPS'], 'VPS exact name');
$p = $params; $p['configoptions'] = ['Backup diario VPS' => 'SYNTHETIC_ACTIVE'];
check(Backups::entitled($p, $config, ['Backup diario VPS']), 'VPS synthetic active');
check(!Backups::entitled($params, $config, ['Backup diario VPS']), 'inapplicable key cannot grant');
$p['configoptions']['Backup diario'] = 'SYNTHETIC_INACTIVE';
check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'conflicting aliases denied');
check(strpos(end($GLOBALS['safeLogs']), 'OPTION_CONFLICT') !== false, 'conflict safe admin diagnostic');
$p['configoptions']['Backup diario'] = 'SYNTHETIC_ACTIVE';
check(Backups::entitled($p, $config, ['Backup diario', 'Backup diario VPS']), 'both require explicit same active value');
$p['configoptions']['Backup diario'] = 1;
$p['configoptions']['Backup diario VPS'] = '1';
check(!Backups::entitled($p, $config, ['Backup diario VPS']), 'type conflict denied');
$GLOBALS['db']['tblproductconfigoptions'][] = (object) ['id' => 12, 'gid' => 5, 'optionname' => 'Backup diario VPS', 'optiontype' => 3];
rejects(fn() => \VPSManager\BackupOption::applicable(3), 'duplicate applicable exact key ambiguous');
$GLOBALS['db']['tblproductconfigoptions'] = [(object) ['id' => 7, 'gid' => 5, 'optionname' => 'Backup diario', 'optiontype' => 3]];
check(!Backups::entitled($params, $config, []), 'option id and yesno definition alone never grant');
$data = parseFixture($html);
check($data['total'] === 5 && $data['full'] === 2 && $data['incremental'] === 3, 'retained totals');
foreach ($expected['copies'] as $i => $row) {
$copy = $data['copies'][$i];
check($copy['duration_minutes'] === $row['duration_minutes'], 'duration fixture');
check(number_format($copy['size_gib'], 2, '.', '') === $row['logical_size_gib_display'], 'total MiB to GiB');
check($copy['incidents'] === 'Sin incidencias registradas', 'filled=no not a failure');
check((new DateTimeImmutable('@' . $copy['start']))->setTimezone(new DateTimeZone('Europe/Madrid'))->format('Y-m-d H:i') === $row['source_start_local'], 'start date');
}
$reordered = altered($html, function ($xp) {
foreach ($xp->query('//table') as $t) {
$rows = iterator_to_array($xp->query('./tr[not(contains(@class,"header"))]', $t));
foreach (array_reverse($rows) as $r) $t->appendChild($r);
}
$tables = iterator_to_array($xp->query('//table'));
foreach (array_reverse($tables) as $t) $t->parentNode->appendChild($t);
});
check(parseFixture($reordered) === $data, 'tables and rows reordered, joins by id');
foreach ([str_replace('Host ' . $host . ' Backup Summary', 'Host wrong.invalid Backup Summary', $html),
file_get_contents(__DIR__ . '/fixtures/backups/hosts-summary.example.html'), '<h1>Login</h1><select>' . $host . '</select>',
'<h1>Host ' . $host . ' Backup Summary</h1>'] as $bad) rejects(fn() => parseFixture($bad), 'identity or missing data');
$noErrors = altered($html, fn($xp) => tableWith($xp, '#Xfer errs')->parentNode->removeChild(tableWith($xp, '#Xfer errs')));
check(parseFixture($noErrors)['copies'][0]['incidents'] === 'Información no disponible', 'missing error table unknown');
$missingCell = altered($html, function ($xp) { $row = $xp->query('./tr', tableWith($xp, '#Xfer errs'))->item(1); $row->removeChild($xp->query('./td', $row)->item(3)); });
check(parseFixture($missingCell)['copies'][0]['incidents'] === 'Información no disponible', 'missing cell not zero');
$incidents = altered($html, function ($xp) { $row = $xp->query('./tr', tableWith($xp, '#Xfer errs'))->item(1); $xp->query('./td', $row)->item(4)->nodeValue = '3'; });
check(parseFixture($incidents)['copies'][0]['incidents'] === 'Con incidencias', 'any positive counter');
$noSize = altered($html, fn($xp) => tableWith($xp, 'Totals')->parentNode->removeChild(tableWith($xp, 'Totals')));
check(parseFixture($noSize)['copies'][0]['size_gib'] === null, 'missing size unknown');
$empty = altered($html, function ($xp) { $t = tableWith($xp, 'Start Date'); foreach (iterator_to_array($xp->query('./tr[position()>1]', $t)) as $r) $t->removeChild($r); });
check(parseFixture($empty)['total'] === 0, 'recognized empty summary is zero');
$more = altered($html, function ($xp) { $t = tableWith($xp, 'Start Date'); $r = $xp->query('./tr', $t)->item(1)->cloneNode(true); $xp->query('./td', $r)->item(0)->nodeValue = '999'; $t->appendChild($r); });
check(parseFixture($more)['total'] === 6, 'six retained');
check(parseFixture(str_replace('state "idle"', 'state "auto disabled"', $html))['activity'] === null, 'unknown activity not healthy');
rejects(fn() => BackupParser::parse($html, $host, ''), 'timezone required');
rejects(fn() => BackupParser::timestamp('2026-03-29 02:30', new DateTimeZone('Europe/Madrid')), 'DST nonexistent');
rejects(fn() => BackupParser::timestamp('2026-10-25 02:30', new DateTimeZone('Europe/Madrid')), 'DST ambiguous');
rejects(fn() => BackupParser::timestamp('2026-02-30 01:00', new DateTimeZone('Europe/Madrid')), 'invalid date');
check(BackupParser::timestamp('2026-03-29 03:30', new DateTimeZone('Europe/Madrid')) > 0, 'valid DST');
$GLOBALS['response'] = ['status' => 200, 'body' => $html];
check(BackupHttp::request($config, $host) === $html, 'HTTP valid');
$call = end($GLOBALS['calls']);
check($call->url === $config['endpoint'] . '?host=' . $host, 'one fixed host read only query');
check($call->options[CURLOPT_FOLLOWLOCATION] === false && $call->options[CURLOPT_SSL_VERIFYPEER] === true && $call->options[CURLOPT_SSL_VERIFYHOST] === 2, 'redirect TLS policy');
check($call->options[CURLOPT_TIMEOUT] === 5 && $call->options[CURLOPT_CONNECTTIMEOUT] === 2, 'bounded time');
foreach ([301, 302, 401, 403, 404, 500] as $status) { $GLOBALS['response']['status'] = $status; rejects(fn() => BackupHttp::request($config, $host), 'HTTP error'); }
foreach (['timeout', 'TLS'] as $failure) { $GLOBALS['response'] = ['timeout' => true]; rejects(fn() => BackupHttp::request($config, $host), $failure . ' curl false'); }
$GLOBALS['response'] = ['status' => 200, 'body' => str_repeat('x', 2097153)];
rejects(fn() => BackupHttp::request($config, $host), 'streaming size limit');
foreach (['http://example.invalid/cgi', 'https://u:p@example.invalid/cgi', 'https://example.invalid/cgi?action=delete'] as $url) rejects(fn() => BackupHttp::request(array_replace($config, ['endpoint' => $url]), $host), 'unsafe endpoint');
// Invoke the production callback with representative WHMCS session/database boundaries.
$GLOBALS['response'] = ['status' => 200, 'body' => $html];
$_GET = ['id' => 43, 'host' => 'OTHER_CUSTOMER.invalid', 'action' => 'deleteBackup']; $_POST = $_GET;
check(BackupAccess::context($params)['host'] === $host, 'mapping loaded from admin DB, request ignored');
check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'eligible button');
$page = vpsmanager_Backups($params);
check(strpos($page['vars']['copiesHtml'], '40,68 GiB') !== false, 'controller renders');
$requests = count($GLOBALS['calls']);
foreach ([[], ['Backup diario' => 'UNKNOWN']] as $option) { $p = $params; $p['configoptions'] = $option; check(strpos(vpsmanager_Backups($p)['vars']['copiesHtml'], 'temporalmente') !== false, 'revocation denies cached data'); }
$p = $params; $p['serviceid'] = 43;
check(strpos(vpsmanager_Backups($p)['vars']['copiesHtml'], 'temporalmente') !== false, 'changing service id denied by owner DB check');
$GLOBALS['actor'] = null;
check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'no session');
$GLOBALS['actor'] = new BackupTestClient(7, false);
check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated user without permission');
$GLOBALS['actor'] = new BackupTestClient(7, true);
check(isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'delegated account context with permission');
check(count($GLOBALS['calls']) === $requests, 'denied requests never query backend');
$GLOBALS['db']['tblcustomfields'][0]->adminonly = '';
check(!isset(vpsmanager_ClientAreaCustomButtonArray($params)['Backups']), 'non-admin field refused');
check(strpos(vpsmanager_Backups($params)['vars']['copiesHtml'], 'temporalmente') !== false, 'invalid mapping generic');
$GLOBALS['db']['tblcustomfields'][0]->adminonly = 'on';
$ctx = ['serviceid' => 99, 'host' => $host]; $time = 1790000000; $calls = 0;
$ok = function () use (&$calls, $html) { $calls++; return $html; };
$fail = static function () { throw new RuntimeException('PRIVATE-ENDPOINT FAKE_SECRET_SENTINEL'); };
$v = Backups::summary($ctx, $config, $ok, $time);
check($v['available'] && !$v['stale'], 'fresh');
check(Backups::summary($ctx, $config, $fail, $time + 119)['stale'] === false, 'fresh cache');
check(Backups::summary($ctx, $config, $fail, $time + 120)['stale'] === true, 'stale marked');
check(Backups::summary($ctx, $config, $fail, $time + 900)['available'], 'stale boundary');
check(!Backups::summary($ctx, $config, $fail, $time + 901)['available'], 'stale expired');
check(!Backups::summary(['serviceid' => 100, 'host' => $host], $config, $fail, $time + 1)['available'], 'service cache isolation');
check(!Backups::summary(['serviceid' => 99, 'host' => 'changed.invalid'], $config, $fail, $time + 1)['available'], 'mapping cache isolation');
check(!Backups::summary($ctx, array_replace($config, ['endpoint' => 'https://new.invalid/cgi']), $fail, $time + 1)['available'], 'destination cache isolation');
$six = Backups::summary(['serviceid' => 101, 'host' => $host], $config, fn() => $more, $time);
check($six['total'] === 6 && count($six['rows']) === 5, 'visible limit independent of total');
$render = '<!doctype html><html><body>' . $page['vars']['copiesHtml'] . '</body></html>';
foreach (['BackupPC', $host, 'OTHER_CUSTOMER', 'PRIVATE-ENDPOINT', 'FAKE_SECRET_SENTINEL', 'backuppc_host', 'num=', 'XferLOG', '367', '2084881'] as $sentinel) check(stripos($render, $sentinel) === false, 'public sentinel absence');
$v['rows'][0]['type'] = '<script>alert(1)</script>';
check(strpos(Backups::render($v), '<script>') === false && strpos(Backups::render($v), '&lt;script&gt;') !== false, 'HTML escaped');
foreach (glob($dir . '/*.json') as $f) {
$cached = file_get_contents($f);
check(strpos($cached, $host) === false && strpos($cached, 'FAKE_SECRET') === false && strpos($cached, '<html') === false, 'private normalized cache only');
check((fileperms($f) & 0077) === 0, 'cache file private');
}
if ($path = getenv('BACKUPS_RENDER_OUTPUT')) file_put_contents($path, $render);
echo 'PASS: ' . ($count - $before) . " backup assertions; synthetic entitlement, WHMCS/HTTP mocks.\n";
} finally {
foreach (glob($dir . '/*') as $f) unlink($f);
rmdir($dir); unlink($temp); putenv('O6H_VPSMANAGER_CONFIG');
}

+ 190
- 0
tests/fixtures/backups/expected-example.json 查看文件

@@ -0,0 +1,190 @@
{
"schema_version": "1.0",
"notice": "Datos de prueba saneados. Numeros internos, estados originales y hosts solo para el parser; no deben copiarse al contrato publico del cliente.",
"host": "fixture-target.invalid",
"source_timezone": null,
"source_timezone_notice": "El HTML no especifica zona horaria; no derivar UTC sin configuracion explicita.",
"full_count": 2,
"incremental_count": 3,
"total_available_count": 5,
"last_complete_backup_start_local": "2026-09-14 01:15",
"copies": [
{
"internal_backup_number": 367,
"source_type": "incr",
"source_filled": "yes",
"source_start_local": "2026-09-19 01:03",
"duration_minutes": 60.3,
"logical_file_count": 2084881,
"logical_size_mib": 41657.0,
"logical_size_gib_display": "40.68",
"source_error_counters": [
0,
0,
0,
0
],
"expected_incident_status": "none_reported"
},
{
"internal_backup_number": 366,
"source_type": "incr",
"source_filled": "no",
"source_start_local": "2026-09-18 01:16",
"duration_minutes": 43.1,
"logical_file_count": 2083988,
"logical_size_mib": 41925.4,
"logical_size_gib_display": "40.94",
"source_error_counters": [
0,
0,
0,
0
],
"expected_incident_status": "none_reported"
},
{
"internal_backup_number": 365,
"source_type": "incr",
"source_filled": "no",
"source_start_local": "2026-09-17 01:14",
"duration_minutes": 39.9,
"logical_file_count": 2083378,
"logical_size_mib": 41595.0,
"logical_size_gib_display": "40.62",
"source_error_counters": [
0,
0,
0,
0
],
"expected_incident_status": "none_reported"
},
{
"internal_backup_number": 362,
"source_type": "full",
"source_filled": "yes",
"source_start_local": "2026-09-14 01:15",
"duration_minutes": 43.8,
"logical_file_count": 2085580,
"logical_size_mib": 41925.2,
"logical_size_gib_display": "40.94",
"source_error_counters": [
0,
0,
0,
0
],
"expected_incident_status": "none_reported"
},
{
"internal_backup_number": 355,
"source_type": "full",
"source_filled": "yes",
"source_start_local": "2026-09-07 01:16",
"duration_minutes": 43.2,
"logical_file_count": 2082561,
"logical_size_mib": 41917.0,
"logical_size_gib_display": "40.93",
"source_error_counters": [
0,
0,
0,
0
],
"expected_incident_status": "none_reported"
}
],
"summary_rows": [
{
"host": "fixture-host-01.invalid",
"group_id": "host_summary_backups",
"full_count": 2,
"incremental_count": 3,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-02.invalid",
"group_id": "host_summary_backups",
"full_count": 3,
"incremental_count": 2,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-03.invalid",
"group_id": "host_summary_backups",
"full_count": 4,
"incremental_count": 8,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-04.invalid",
"group_id": "host_summary_backups",
"full_count": 2,
"incremental_count": 8,
"last_backup_age_days": 331.4,
"source_state": "auto disabled",
"source_last_attempt": ""
},
{
"host": "fixture-host-05.invalid",
"group_id": "host_summary_backups",
"full_count": 2,
"incremental_count": 8,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-06.invalid",
"group_id": "host_summary_backups",
"full_count": 3,
"incremental_count": 2,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-07.invalid",
"group_id": "host_summary_backups",
"full_count": 2,
"incremental_count": 3,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-target.invalid",
"group_id": "host_summary_backups",
"full_count": 2,
"incremental_count": 3,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-09.invalid",
"group_id": "host_summary_backups",
"full_count": 2,
"incremental_count": 3,
"last_backup_age_days": 0.4,
"source_state": "idle",
"source_last_attempt": "done"
},
{
"host": "fixture-host-08.invalid",
"group_id": "host_summary_nobackups",
"full_count": 0,
"incremental_count": 0,
"last_backup_age_days": null,
"source_state": "idle",
"source_last_attempt": "no ping (no ping response from 192.0.2.10)"
}
]
}

+ 451
- 0
tests/fixtures/backups/host-detail.example.html 查看文件

@@ -0,0 +1,451 @@
<!doctype html public "-//W3C//DTD HTML 4.01 Transitional//EN">
<html><head>
<title>BackupPC: Host fixture-target.invalid Backup Summary</title>
<link rel=stylesheet type="text/css" href="/BackupPC/BackupPC_stnd.css?version=2" title="CSSFile">

<link rel="apple-touch-icon" sizes="180x180" href="/BackupPC/apple-touch-icon.png?v=2">
<link rel="icon" type="image/png" sizes="32x32" href="/BackupPC/favicon-32x32.png?v=2">
<link rel="icon" type="image/png" sizes="16x16" href="/BackupPC/favicon-16x16.png?v=2">
<link rel="mask-icon" href="/BackupPC/safari-pinned-tab.svg?v=2" color="#5bbad5">
<link rel="shortcut icon" href="/BackupPC/favicon.ico?v=2">

<meta http-equiv="pragma" content="no-cache">
<script src="/BackupPC/sorttable.js"></script>
</head><body onLoad="document.getElementById('NavMenu').style.height=document.body.scrollHeight">

<div id="navigation-container">
<div id="logo-container">
<a href="https://backuppc.github.io/backuppc/"><img src="/BackupPC/logo320.png"></a>
</div>
<div class="NavMenu section-title"><h2 class="NavTitle">fixture-target.invalid</h2>
</div>
<div class="NavMenu host">
<a href="/BackupPC_Admin?host=fixture-target.invalid" class="NavCurrent">fixture-target.invalid Home</a>
<a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid">Browse backups</a>
<a href="/BackupPC_Admin?action=view&type=LOG&host=fixture-target.invalid">LOG file</a>
<a href="/BackupPC_Admin?action=LOGlist&host=fixture-target.invalid">LOG files</a>
<a href="/BackupPC_Admin?action=editConfig&host=fixture-target.invalid">Edit Config</a>
</div>
<div class="NavMenu" id="NavMenu">
<h2 class="NavTitle">Hosts</h2>
<select onChange="document.location=this.value">
<option value="#">Select a host...</option><option value="?host=fixture-host-01.invalid">fixture-host-01.invalid</option><option value="?host=fixture-host-02.invalid">fixture-host-02.invalid</option><option value="?host=fixture-host-03.invalid">fixture-host-03.invalid</option><option value="?host=fixture-host-04.invalid">fixture-host-04.invalid</option><option value="?host=fixture-host-05.invalid">fixture-host-05.invalid</option><option value="?host=fixture-host-06.invalid">fixture-host-06.invalid</option><option value="?host=fixture-host-07.invalid">fixture-host-07.invalid</option><option value="?host=fixture-host-08.invalid">fixture-host-08.invalid</option><option value="?host=fixture-target.invalid" selected>fixture-target.invalid</option><option value="?host=fixture-host-09.invalid">fixture-host-09.invalid</option>
</select>
<form action="/BackupPC_Admin" method="get">
<input type="text" name="host" size="14" maxlength="64">
<input type="hidden" name="action" value="hostInfo"><input type="submit" value="Go" name="ignore">
</form>
<h2 class="NavTitle">Server</h2>
<a href="/BackupPC_Admin?action=status">Status</a>
<a href="/BackupPC_Admin?action=summary">Host Summary</a>
<a href="/BackupPC_Admin?action=editConfig">Edit Config</a>
<a href="/BackupPC_Admin?action=editConfig&newMenu=hosts">Edit Hosts</a>
<a href="/BackupPC_Admin?action=adminOpts">Admin Options</a>
<a href="/BackupPC_Admin?action=view&type=LOG">LOG file</a>
<a href="/BackupPC_Admin?action=LOGlist">Old LOGs</a>
<a href="/BackupPC_Admin?action=emailSummary">Email summary</a>
<a href="/BackupPC_Admin?action=queue">Current queues</a>
<a href="/BackupPC_Admin?action=view&type=docs">Documentation</a>
<a href="https://github.com/backuppc/backuppc/wiki">Wiki</a>
<a href="https://backuppc.github.io/backuppc">Homepage</a>
</div>
</div> <!-- end #navigation-container -->
<div id="Content">
<h1>Host fixture-target.invalid Backup Summary</h1>

<p>

<ul>
<li>Last status is state "idle" (done) as of 2026-09-19 02:03.
<li>Pings to fixture-target.invalid have succeeded 292
consecutive times.
<li>Because fixture-target.invalid has been on the network at least 7
consecutive times, it will not be backed up from 7:00 to 19:30 on Mon, Tue, Wed, Thu, Fri.

</ul>
</p>
<h2>User Actions</h2>

<p>
<form name="StartStopForm" action="/BackupPC_Admin" method="get">
<input type="hidden" name="host" value="fixture-target.invalid">
<input type="hidden" name="action" value="">
<input type="button" value="Start Incr Backup"
onClick="document.StartStopForm.action.value='Start_Incr_Backup';
document.StartStopForm.submit();">

<input type="button" value="Start Full Backup"
onClick="document.StartStopForm.action.value='Start_Full_Backup';
document.StartStopForm.submit();">
<input type="button" value="Stop/Dequeue Backup"
onClick="document.StartStopForm.action.value='Stop_Dequeue_Backup';
document.StartStopForm.submit();">
</form>
</p>
<h2>Backup Summary</h2>

<p>
Click on the backup number to browse and restore backup files.
</p>
<table class="tableStnd sortable" border cellspacing="1" cellpadding="3">
<tr class="tableheader"><td align="center"> Backup# </td>
<td align="center"> Type </td>
<td align="center"> Filled </td>
<td align="center"> Level </td>
<td align="center"> Start Date </td>
<td align="center"> Duration/mins </td>
<td align="center"> Age/days </td>
<td align="center"> Keep </td>
<td align="center"> </td>
<td align="center"> Comment </td>
</tr>
<tr>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=367">367</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> yes </td>
<td align="center" class="border"> 1 </td>
<td align="right" class="border" data-date_format="2"> 2026-09-19 01:03 </td>
<td align="right" class="border"> 60.3 </td>
<td align="right" class="border"> 0.4 </td>
<td class="border">
</td>
<td class="border">
<form name="DeleteForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="hidden" name="action" value="deleteBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
<input type="hidden" name="num" value="367">
<input type="hidden" name="nofill" value="0">
<input type="hidden" name="type" value="incr">
<input type="submit" value="Delete">
</form>
</td>

<td align="left" class="border"> </td></tr>

<tr>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=366">366</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> no </td>
<td align="center" class="border"> 1 </td>
<td align="right" class="border" data-date_format="2"> 2026-09-18 01:16 </td>
<td align="right" class="border"> 43.1 </td>
<td align="right" class="border"> 1.4 </td>
<td class="border">
</td>
<td class="border">
<form name="DeleteForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="hidden" name="action" value="deleteBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
<input type="hidden" name="num" value="366">
<input type="hidden" name="nofill" value="1">
<input type="hidden" name="type" value="incr">
<input type="submit" value="Delete">
</form>
</td>

<td align="left" class="border"> </td></tr>

<tr>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=365">365</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> no </td>
<td align="center" class="border"> 1 </td>
<td align="right" class="border" data-date_format="2"> 2026-09-17 01:14 </td>
<td align="right" class="border"> 39.9 </td>
<td align="right" class="border"> 2.4 </td>
<td class="border">
</td>
<td class="border">
<form name="DeleteForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="hidden" name="action" value="deleteBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
<input type="hidden" name="num" value="365">
<input type="hidden" name="nofill" value="1">
<input type="hidden" name="type" value="incr">
<input type="submit" value="Delete">
</form>
</td>

<td align="left" class="border"> </td></tr>

<tr>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=362">362</a> </td>
<td align="center" class="border"> full </td>
<td align="center" class="border"> yes </td>
<td align="center" class="border"> 0 </td>
<td align="right" class="border" data-date_format="2"> 2026-09-14 01:15 </td>
<td align="right" class="border"> 43.8 </td>
<td align="right" class="border"> 5.4 </td>
<td class="border">
<form name="KeepForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="checkbox" name="keep" value="1" onchange="this.form.submit()">
<input type="hidden" name="num" value="362">
<input type="hidden" name="action" value="keepBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
</form><span style="display:none">0<!- for sorting -></span></td>
</td>
<td class="border">
<form name="DeleteForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="hidden" name="action" value="deleteBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
<input type="hidden" name="num" value="362">
<input type="hidden" name="nofill" value="0">
<input type="hidden" name="type" value="full">
<input type="submit" value="Delete">
</form>
</td>

<td align="left" class="border"> </td></tr>

<tr>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=355">355</a> </td>
<td align="center" class="border"> full </td>
<td align="center" class="border"> yes </td>
<td align="center" class="border"> 0 </td>
<td align="right" class="border" data-date_format="2"> 2026-09-07 01:16 </td>
<td align="right" class="border"> 43.2 </td>
<td align="right" class="border"> 12.4 </td>
<td class="border">
<form name="KeepForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="checkbox" name="keep" value="1" onchange="this.form.submit()">
<input type="hidden" name="num" value="355">
<input type="hidden" name="action" value="keepBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
</form><span style="display:none">0<!- for sorting -></span></td>
</td>
<td class="border">
<form name="DeleteForm" action="/BackupPC_Admin" method="get" style="margin: 0px;">
<input type="hidden" name="action" value="deleteBackup">
<input type="hidden" name="host" value="fixture-target.invalid">
<input type="hidden" name="num" value="355">
<input type="hidden" name="nofill" value="0">
<input type="hidden" name="type" value="full">
<input type="submit" value="Delete">
</form>
</td>

<td align="left" class="border"> </td></tr>

</table>
<p>


</p>
<h2>Xfer Error Summary</h2>

<table class="tableStnd sortable" border cellspacing="1" cellpadding="3" width="80%">
<tr class="tableheader"><td align="center"> Backup# </td>
<td align="center"> Type </td>
<td align="center"> View </td>
<td align="center"> #Xfer errs </td>
<td align="center"> #bad files </td>
<td align="center"> #bad share </td>
<td align="center"> #tar errs </td>
</tr>
<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=367">367</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=view&type=XferLOG&num=367&host=fixture-target.invalid">XferLOG</a>,
<a href="/BackupPC_Admin?action=view&type=XferErr&num=367&host=fixture-target.invalid">Errors</a> </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td></tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=366">366</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=view&type=XferLOG&num=366&host=fixture-target.invalid">XferLOG</a>,
<a href="/BackupPC_Admin?action=view&type=XferErr&num=366&host=fixture-target.invalid">Errors</a> </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td></tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=365">365</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=view&type=XferLOG&num=365&host=fixture-target.invalid">XferLOG</a>,
<a href="/BackupPC_Admin?action=view&type=XferErr&num=365&host=fixture-target.invalid">Errors</a> </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td></tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=362">362</a> </td>
<td align="center" class="border"> full </td>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=view&type=XferLOG&num=362&host=fixture-target.invalid">XferLOG</a>,
<a href="/BackupPC_Admin?action=view&type=XferErr&num=362&host=fixture-target.invalid">Errors</a> </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td></tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=355">355</a> </td>
<td align="center" class="border"> full </td>
<td align="center" class="border"> <a href="/BackupPC_Admin?action=view&type=XferLOG&num=355&host=fixture-target.invalid">XferLOG</a>,
<a href="/BackupPC_Admin?action=view&type=XferErr&num=355&host=fixture-target.invalid">Errors</a> </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td>
<td align="right" class="border"> 0 </td></tr>

</table>

<h2>File Size/Count Reuse Summary</h2>

<p>
Existing files are those already in the pool; new files are those added
to the pool.
Empty files and SMB errors aren't counted in the reuse and new counts.
</p>
<table class="tableStnd sortable" border cellspacing="1" cellpadding="3" width="80%">
<tr class="tableheader"><td colspan="2" bgcolor="#ffffff"></td>
<td align="center" colspan="3"> Totals </td>
<td align="center" colspan="2"> Existing Files </td>
<td align="center" colspan="2"> New Files </td>
</tr>
<tr class="tableheader sortheader">
<td align="center"> Backup# </td>
<td align="center"> Type </td>
<td align="center"> #Files </td>
<td align="center"> Size/MiB </td>
<td align="center"> MiB/sec </td>
<td align="center"> #Files </td>
<td align="center"> Size/MiB </td>
<td align="center"> #Files </td>
<td align="center"> Size/MiB </td>
</tr>
<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=367">367</a> </td>
<td align="center" class="border"> incr </td>
<td align="right" class="border"> 2084881 </td>
<td align="right" class="border"> 41657.0 </td>
<td align="right" class="border"> 11.52 </td>
<td align="right" class="border"> 9933 </td>
<td align="right" class="border"> 193.8 </td>
<td align="right" class="border"> 1811 </td>
<td align="right" class="border"> 2151.9 </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=366">366</a> </td>
<td align="center" class="border"> incr </td>
<td align="right" class="border"> 2083988 </td>
<td align="right" class="border"> 41925.4 </td>
<td align="right" class="border"> 16.22 </td>
<td align="right" class="border"> 6448 </td>
<td align="right" class="border"> 109.0 </td>
<td align="right" class="border"> 1812 </td>
<td align="right" class="border"> 2186.1 </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=365">365</a> </td>
<td align="center" class="border"> incr </td>
<td align="right" class="border"> 2083378 </td>
<td align="right" class="border"> 41595.0 </td>
<td align="right" class="border"> 17.36 </td>
<td align="right" class="border"> 2583 </td>
<td align="right" class="border"> 53.0 </td>
<td align="right" class="border"> 1850 </td>
<td align="right" class="border"> 2170.9 </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=362">362</a> </td>
<td align="center" class="border"> full </td>
<td align="right" class="border"> 2085580 </td>
<td align="right" class="border"> 41925.2 </td>
<td align="right" class="border"> 15.97 </td>
<td align="right" class="border"> 1279 </td>
<td align="right" class="border"> 34.7 </td>
<td align="right" class="border"> 1801 </td>
<td align="right" class="border"> 1334.2 </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=355">355</a> </td>
<td align="center" class="border"> full </td>
<td align="right" class="border"> 2082561 </td>
<td align="right" class="border"> 41917.0 </td>
<td align="right" class="border"> 16.18 </td>
<td align="right" class="border"> 1209 </td>
<td align="right" class="border"> 49.2 </td>
<td align="right" class="border"> 1772 </td>
<td align="right" class="border"> 1561.8 </td>
</tr>

</table>

<h2>Compression Summary</h2>

<p>
Compression performance for files already in the pool and newly
compressed files.
</p>
<table class="tableStnd sortable" border cellspacing="1" cellpadding="3" width="80%">
<tr class="tableheader"><td colspan="3" bgcolor="#ffffff"></td>
<td align="center" colspan="3"> Existing Files </td>
<td align="center" colspan="3"> New Files </td>
</tr>
<tr class="tableheader sortheader"><td align="center"> Backup# </td>
<td align="center"> Type </td>
<td align="center"> Comp Level </td>
<td align="center"> Size/MiB </td>
<td align="center"> Comp/MiB </td>
<td align="center"> Comp </td>
<td align="center"> Size/MiB </td>
<td align="center"> Comp/MiB </td>
<td align="center"> Comp </td>
</tr>
<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=367">367</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> 3 </td>
<td align="right" class="border"> 193.8 </td>
<td align="right" class="border"> 155.1 </td>
<td align="right" class="border"> 20.0% </td>
<td align="right" class="border"> 2151.9 </td>
<td align="right" class="border"> 553.2 </td>
<td align="right" class="border"> 74.3% </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=366">366</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> 3 </td>
<td align="right" class="border"> 109.0 </td>
<td align="right" class="border"> 94.3 </td>
<td align="right" class="border"> 13.5% </td>
<td align="right" class="border"> 2186.1 </td>
<td align="right" class="border"> 559.2 </td>
<td align="right" class="border"> 74.4% </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=365">365</a> </td>
<td align="center" class="border"> incr </td>
<td align="center" class="border"> 3 </td>
<td align="right" class="border"> 53.0 </td>
<td align="right" class="border"> 40.3 </td>
<td align="right" class="border"> 24.0% </td>
<td align="right" class="border"> 2170.9 </td>
<td align="right" class="border"> 556.0 </td>
<td align="right" class="border"> 74.4% </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=362">362</a> </td>
<td align="center" class="border"> full </td>
<td align="center" class="border"> 3 </td>
<td align="right" class="border"> 34.7 </td>
<td align="right" class="border"> 24.7 </td>
<td align="right" class="border"> 28.9% </td>
<td align="right" class="border"> 1334.2 </td>
<td align="right" class="border"> 694.0 </td>
<td align="right" class="border"> 48.0% </td>
</tr>

<tr><td align="center" class="border"> <a href="/BackupPC_Admin?action=browse&host=fixture-target.invalid&num=355">355</a> </td>
<td align="center" class="border"> full </td>
<td align="center" class="border"> 3 </td>
<td align="right" class="border"> 49.2 </td>
<td align="right" class="border"> 33.9 </td>
<td align="right" class="border"> 31.0% </td>
<td align="right" class="border"> 1561.8 </td>
<td align="right" class="border"> 741.7 </td>
<td align="right" class="border"> 52.5% </td>
</tr>

</table>

</body></html>

+ 239
- 0
tests/fixtures/backups/hosts-summary.example.html 查看文件

@@ -0,0 +1,239 @@
<!doctype html public "-//W3C//DTD HTML 4.01 Transitional//EN">
<html><head>
<title>BackupPC: Host Summary</title>
<link rel=stylesheet type="text/css" href="/BackupPC/BackupPC_stnd.css?version=2" title="CSSFile">

<link rel="apple-touch-icon" sizes="180x180" href="/BackupPC/apple-touch-icon.png?v=2">
<link rel="icon" type="image/png" sizes="32x32" href="/BackupPC/favicon-32x32.png?v=2">
<link rel="icon" type="image/png" sizes="16x16" href="/BackupPC/favicon-16x16.png?v=2">
<link rel="mask-icon" href="/BackupPC/safari-pinned-tab.svg?v=2" color="#5bbad5">
<link rel="shortcut icon" href="/BackupPC/favicon.ico?v=2">

<meta http-equiv="pragma" content="no-cache">
<script src="/BackupPC/sorttable.js"></script>
</head><body onLoad="document.getElementById('NavMenu').style.height=document.body.scrollHeight">

<div id="navigation-container">
<div id="logo-container">
<a href="https://backuppc.github.io/backuppc/"><img src="/BackupPC/logo320.png"></a>
</div>
<div class="NavMenu" id="NavMenu">
<h2 class="NavTitle">Hosts</h2>
<select onChange="document.location=this.value">
<option value="#">Select a host...</option><option value="?host=fixture-host-01.invalid">fixture-host-01.invalid</option><option value="?host=fixture-host-02.invalid">fixture-host-02.invalid</option><option value="?host=fixture-host-03.invalid">fixture-host-03.invalid</option><option value="?host=fixture-host-04.invalid">fixture-host-04.invalid</option><option value="?host=fixture-host-05.invalid">fixture-host-05.invalid</option><option value="?host=fixture-host-06.invalid">fixture-host-06.invalid</option><option value="?host=fixture-host-07.invalid">fixture-host-07.invalid</option><option value="?host=fixture-host-08.invalid">fixture-host-08.invalid</option><option value="?host=fixture-target.invalid">fixture-target.invalid</option><option value="?host=fixture-host-09.invalid">fixture-host-09.invalid</option>
</select>
<form action="/BackupPC_Admin" method="get">
<input type="text" name="host" size="14" maxlength="64">
<input type="hidden" name="action" value="hostInfo"><input type="submit" value="Go" name="ignore">
</form>
<h2 class="NavTitle">Server</h2>
<a href="/BackupPC_Admin?action=status">Status</a>
<a href="/BackupPC_Admin?action=summary" class="NavCurrent">Host Summary</a>
<a href="/BackupPC_Admin?action=editConfig">Edit Config</a>
<a href="/BackupPC_Admin?action=editConfig&newMenu=hosts">Edit Hosts</a>
<a href="/BackupPC_Admin?action=adminOpts">Admin Options</a>
<a href="/BackupPC_Admin?action=view&type=LOG">LOG file</a>
<a href="/BackupPC_Admin?action=LOGlist">Old LOGs</a>
<a href="/BackupPC_Admin?action=emailSummary">Email summary</a>
<a href="/BackupPC_Admin?action=queue">Current queues</a>
<a href="/BackupPC_Admin?action=view&type=docs">Documentation</a>
<a href="https://github.com/backuppc/backuppc/wiki">Wiki</a>
<a href="https://backuppc.github.io/backuppc">Homepage</a>
</div>
</div> <!-- end #navigation-container -->
<div id="Content">

<h1>BackupPC: Host Summary</h1>

<p>
<ul>
<li>This status was generated at 2026-09-19 11:13.
<li>Pool file system was recently at 16%
(2026-09-19 11:10), today's max is 16% (2026-09-18 16:00)
and yesterday's max was 17%.
<li>Pool file system inode usage was recently at 1%
(2026-09-19 11:10), today's max is 1% (2026-09-18 16:00)
and yesterday's max was 1%.
</ul>
</p>

<h2>Hosts with good Backups</h2>

<p>
There are 9 hosts that have been backed up, for a total of:
<ul>
<li> 22 full backups of total size 4003.59GiB
(prior to pooling and compression),
<li> 40 incr backups of total size 8435.88GiB
(prior to pooling and compression).
</ul>
</p>
<table class="sortable" id="host_summary_backups" border cellpadding="3" cellspacing="1">
<tr class="tableheader"><td> Host </td>
<td align="center"> User </td>
<td align="center"> Comment </td>
<td align="center"> #Full </td>
<td align="center"> Full Age (days) </td>
<td align="center"> Full Size (GiB) </td>
<td align="center"> Speed (MiB/s) </td>
<td align="center"> #Incr </td>
<td align="center"> Incr Age (days) </td>
<td align="center"> Last Backup (days) </td>
<td align="center"> State </td>
<td align="center"> #Xfer errs </td>
<td align="center"> Last attempt </td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-01.invalid">fixture-host-01.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">2</td>
<td align="center" class="border">6.4</td>
<td align="center" class="border">30.29</td>
<td align="center" class="border">25.48</td>
<td align="center" class="border">3</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-02.invalid">fixture-host-02.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">3</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">105.20</td>
<td align="center" class="border">60.32</td>
<td align="center" class="border">2</td>
<td align="center" class="border">1.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-03.invalid">fixture-host-03.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">4</td>
<td align="center" class="border">6.4</td>
<td align="center" class="border">438.65</td>
<td align="center" class="border">114.56</td>
<td align="center" class="border">8</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#d1d1d1"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-04.invalid">fixture-host-04.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">2</td>
<td align="center" class="border">332.4</td>
<td align="center" class="border">112.41</td>
<td align="center" class="border">158.33</td>
<td align="center" class="border">8</td>
<td align="center" class="border">331.4</td>
<td align="center" class="border">331.4</td>
<td align="center" class="border">auto disabled</td>
<td align="center" class="border">0</td>
<td class="border"></td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-05.invalid">fixture-host-05.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">2</td>
<td align="center" class="border">4.4</td>
<td align="center" class="border">305.29</td>
<td align="center" class="border">189.93</td>
<td align="center" class="border">8</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-06.invalid">fixture-host-06.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">3</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">112.46</td>
<td align="center" class="border">27.79</td>
<td align="center" class="border">2</td>
<td align="center" class="border">1.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-07.invalid">fixture-host-07.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">2</td>
<td align="center" class="border">6.4</td>
<td align="center" class="border">109.00</td>
<td align="center" class="border">30.47</td>
<td align="center" class="border">3</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-target.invalid">fixture-target.invalid</a></td>
<td align="center" class="border"> </td>
<td align="center" class="border"></td>
<td align="center" class="border">2</td>
<td align="center" class="border">5.4</td>
<td align="center" class="border">40.94</td>
<td align="center" class="border">15.97</td>
<td align="center" class="border">3</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>
<tr bgcolor="#ccffcc"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-09.invalid">fixture-host-09.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">2</td>
<td align="center" class="border">6.4</td>
<td align="center" class="border">211.57</td>
<td align="center" class="border">50.99</td>
<td align="center" class="border">3</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">0.4</td>
<td align="center" class="border">idle</td>
<td align="center" class="border">0</td>
<td class="border">done</td></tr>

</table>
<h2>Hosts with no Backups</h2>

<p>
There are 1 hosts with no backups.
<p>
<table class="sortable" id="host_summary_nobackups" border cellpadding="3" cellspacing="1">
<tr class="tableheader"><td> Host </td>
<td align="center"> User </td>
<td align="center"> Comment </td>
<td align="center"> #Full </td>
<td align="center"> Full Age (days) </td>
<td align="center"> Full Size (GiB) </td>
<td align="center"> Speed (MiB/s) </td>
<td align="center"> #Incr </td>
<td align="center"> Incr Age/days </td>
<td align="center"> Last Backup (days) </td>
<td align="center"> State </td>
<td align="center"> #Xfer errs </td>
<td align="center"> Last attempt </td></tr>
<tr bgcolor="#ffff99"><td class="border"><a href="/BackupPC_Admin?host=fixture-host-08.invalid">fixture-host-08.invalid</a></td>
<td align="center" class="border"> <a href="mailto:fixture-user">fixture-user</a> </td>
<td align="center" class="border"></td>
<td align="center" class="border">0</td>
<td align="center" class="border"></td>
<td align="center" class="border">0.00</td>
<td align="center" class="border"></td>
<td align="center" class="border">0</td>
<td align="center" class="border">&nbsp;</td>
<td align="center" class="border"></td>
<td align="center" class="border">idle</td>
<td align="center" class="border"></td>
<td class="border">no ping (no ping response from 192.0.2.10)</td></tr>

</table>

</body></html>

+ 3
- 3
tools/audit.py 查看文件

@@ -73,18 +73,18 @@ for name in sorted(files):
review.append(f'{name}:{number}')
if private_key.search(line) or known_token.search(line):
flag(name, number, 'private key or known credential pattern')
if url_auth.search(line) and not (name == 'tests/run.php' and 'example.invalid' in line):
if url_auth.search(line) and not (name in ('tests/run.php', 'tests/backups.php') and 'example.invalid' in line):
flag(name, number, 'credential embedded in URL')
if private_ip.search(line):
flag(name, number, 'private IP literal')
for match in credential.finditer(line):
value = match.group(2)
if name == 'tests/run.php' and value.startswith('FAKE_'):
if name in ('tests/run.php', 'tests/backups.php') and value.startswith('FAKE_'):
continue
flag(name, number, 'nonempty credential-like literal')
for match in quote.finditer(line):
value = match.group(1)
if name == 'tests/run.php' and value.startswith('FAKE_'):
if name in ('tests/run.php', 'tests/backups.php') and value.startswith('FAKE_'):
continue
if 'example.invalid' in value or re.fullmatch(r'[0-9a-f-]{36}', value):
continue


+ 10
- 2
vpsmanager.php 查看文件

@@ -12,6 +12,7 @@ if (!defined('WHMCS')) {

require_once __DIR__ . '/lib/VPSManager.php';
require_once __DIR__ . '/lib/ClientView.php';
require_once __DIR__ . '/lib/Backups.php';
require_once __DIR__ . '/WhmcsHelpers/CustomField.php';

use VPSManager\VPSManager;
@@ -100,15 +101,22 @@ function vpsmanager_ChangePassword( $params ) {

}

function vpsmanager_ClientAreaCustomButtonArray()
function vpsmanager_ClientAreaCustomButtonArray(array $params = [])
{
return array(
$buttons = array(
"Reiniciar" => "Reiniciar",
"Detener" => "Detener",
"Iniciar" => "Iniciar",
"Firewall" => "Firewall",
"Snapshots" => "Snapshots",
);
if (\VPSManager\Backups::button($params)) $buttons["Backups"] = "Backups";
return $buttons;
}

function vpsmanager_Backups(array $params)
{
return \VPSManager\Backups::page($params);
}




正在加载...
取消
保存