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