|
- <?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();
- }
- }
|