Módulo VPSManager para WHMCS con configuración externa, acceso seguro al panel y métricas Graphite para LX.
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 
 
 

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