Módulo VPSManager para WHMCS con configuración externa, acceso seguro al panel y métricas Graphite para LX.
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 
 
 

127 lines
7.0 KiB

  1. <?php
  2. namespace VPSManager;
  3. require_once __DIR__ . '/BackupAccess.php';
  4. require_once __DIR__ . '/BackupParser.php';
  5. require_once __DIR__ . '/BackupHttp.php';
  6. require_once __DIR__ . '/BackupCache.php';
  7. final class Backups
  8. {
  9. public static function validHost($value): bool
  10. {
  11. return is_string($value) && preg_match('/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/', $value) === 1;
  12. }
  13. public static function limit(array $config, string $key, int $default, int $min, int $max): int
  14. {
  15. $value = $config[$key] ?? $default;
  16. if (!is_int($value) || $value < $min || $value > $max) throw new \RuntimeException('CONFIG_LIMIT');
  17. return $value;
  18. }
  19. public static function entitled(array $params, array $config, array $keys): bool
  20. {
  21. $options = is_array($params['configoptions'] ?? null) ? $params['configoptions'] : [];
  22. if (BackupOption::conflict($options)) {
  23. self::diagnostic('OPTION_CONFLICT', (int) ($params['serviceid'] ?? 0));
  24. return false;
  25. }
  26. // Observed in WHMCS 8.13.3: checked => integer 1, unchecked => integer 0.
  27. // The installation now uses the single exact name Backup diario.
  28. // Legacy aliases remain detectable for conflicts, but do not grant access.
  29. return ($config['enabled'] ?? false) === true
  30. && $keys === ['Backup diario']
  31. && array_key_exists('Backup diario', $options)
  32. && $options['Backup diario'] === 1;
  33. }
  34. public static function diagnostic(string $code, int $service): void
  35. {
  36. // Codes are chosen by this module, never exception messages or backend content.
  37. if (function_exists('logActivity')) logActivity('VPSManager copies ' . $code . ' service=' . $service);
  38. }
  39. public static function button(array $params): bool
  40. {
  41. try {
  42. $context = BackupAccess::context($params);
  43. $config = Config::load()['backups'] ?? [];
  44. if (!$context || !self::entitled($params, $config, $context['option_keys'])) return false;
  45. if (!self::validHost($context['host'])) {
  46. self::diagnostic('MAPPING', $context['serviceid']);
  47. return false;
  48. }
  49. return true;
  50. } catch (\Throwable $e) { return false; }
  51. }
  52. public static function page(array $params): array
  53. {
  54. $view = ['available' => false];
  55. try {
  56. // The same authorization and entitlement precede both button and page.
  57. $context = BackupAccess::context($params);
  58. $config = Config::load()['backups'] ?? [];
  59. if ($context && self::entitled($params, $config, $context['option_keys'])) {
  60. if (self::validHost($context['host'])) $view = self::summary($context, $config);
  61. else self::diagnostic('MAPPING', $context['serviceid']);
  62. }
  63. } catch (\Throwable $e) { /* Generic public response only. */ }
  64. if (!headers_sent()) header('Cache-Control: private, no-store, max-age=0');
  65. return ['templatefile' => 'templates/backups', 'vars' => ['copiesHtml' => self::render($view)]];
  66. }
  67. // Internal service: callers must authorize each request before entering this method.
  68. public static function summary(array $context, array $config, ?callable $transport = null, ?int $now = null): array
  69. {
  70. $now = $now ?? time();
  71. if (!self::validHost($context['host'] ?? null)) return ['available' => false];
  72. $cache = null; $entry = null;
  73. // Configuration hash invalidates data on any policy, destination or credential change.
  74. // Only a digest is stored in the filename; no configuration or secrets in the value.
  75. $key = hash('sha256', serialize(['v1', $config, $context['serviceid'], $context['host']]));
  76. $ttl = self::limit($config, 'cache_ttl_seconds', 120, 0, 900);
  77. $stale = self::limit($config, 'stale_max_age_seconds', 900, 0, 900);
  78. try {
  79. $cache = new BackupCache($config['cache_directory'] ?? '');
  80. $entry = $cache->read($key);
  81. } catch (\Throwable $e) { self::diagnostic('CACHE', $context['serviceid']); }
  82. $age = is_int($entry['obtained_at'] ?? null) ? $now - $entry['obtained_at'] : PHP_INT_MAX;
  83. if ($age >= 0 && $age < min($ttl, $stale) && is_array($entry['data'] ?? null)) return self::publicView($entry, false, $config, $now);
  84. try {
  85. $body = $transport ? $transport($config, $context['host']) : BackupHttp::getHostSummary($config, $context['host']);
  86. if (!is_string($body) || strlen($body) > self::limit($config, 'max_response_bytes', 2097152, 1024, 2097152)) throw new \RuntimeException('SIZE');
  87. $data = BackupParser::parse($body, $context['host'], $config['source_timezone'] ?? '');
  88. $entry = ['obtained_at' => $now, 'data' => $data];
  89. if ($cache) {
  90. try { $cache->write($key, $entry); } catch (\Throwable $e) { self::diagnostic('CACHE', $context['serviceid']); }
  91. }
  92. return self::publicView($entry, false, $config, $now);
  93. } catch (\Throwable $e) {
  94. self::diagnostic('SOURCE', $context['serviceid']);
  95. if ($age >= 0 && $age <= $stale && is_array($entry['data'] ?? null)) return self::publicView($entry, true, $config, $now);
  96. return ['available' => false];
  97. }
  98. }
  99. private static function publicView(array $entry, bool $stale, array $config, int $now): array
  100. {
  101. $data = $entry['data'];
  102. $zone = new \DateTimeZone($data['timezone']);
  103. $date = static fn($ts) => $ts === null ? 'Información no disponible' : (new \DateTimeImmutable('@' . $ts))->setTimezone($zone)->format('d/m/Y H:i T');
  104. $rows = [];
  105. foreach (array_slice($data['copies'], 0, self::limit($config, 'max_visible_copies', 5, 1, 50)) as $copy) {
  106. $rows[] = ['start' => $date($copy['start']), 'type' => $copy['type'],
  107. 'duration' => $copy['duration_minutes'] === null ? 'Información no disponible' : number_format($copy['duration_minutes'], 1, ',', '.') . ' min',
  108. 'size' => $copy['size_gib'] === null ? 'Información no disponible' : number_format($copy['size_gib'], 2, ',', '.') . ' GiB',
  109. 'incidents' => $copy['incidents']];
  110. }
  111. return ['available' => true, 'stale' => $stale, 'updated' => $date($entry['obtained_at']),
  112. 'timezone' => $data['timezone'], 'activity' => $data['activity'],
  113. 'total' => $data['total'], 'full' => $data['full'], 'incremental' => $data['incremental'],
  114. 'last_full' => $date($data['last_full']), 'rows' => $rows,
  115. 'age' => isset($data['copies'][0]) ? max(0, (int) floor(($now - $data['copies'][0]['start']) / 3600)) : null];
  116. }
  117. public static function render(array $view): string
  118. {
  119. $escape = static fn($value) => htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
  120. ob_start();
  121. // Isolated scope: the template receives only public allowlisted presentation data.
  122. require __DIR__ . '/../templates/backups.php';
  123. return ob_get_clean();
  124. }
  125. }