'data', 'glob' => 'data/*.json']]); } if (!defined('MANAGE_BACKUP_LOCAL_RETENTION')) { define('MANAGE_BACKUP_LOCAL_RETENTION', 4); } if (!defined('MANAGE_BACKUP_AUTO_INTERVAL_SECONDS')) { define('MANAGE_BACKUP_AUTO_INTERVAL_SECONDS', 604800); } if (!defined('MANAGE_BACKUP_COMPRESS')) { define('MANAGE_BACKUP_COMPRESS', true); } if (!defined('MANAGE_BACKUP_UPLOAD')) { define('MANAGE_BACKUP_UPLOAD', true); } // Raised when the backup upload fails. Carries a scrubbed debug context that is // safe to log: the token is never part of it. class ManageRemoteUploadException extends RuntimeException { private array $debugContext; public function __construct(string $message, array $debugContext = []) { parent::__construct($message); $this->debugContext = $debugContext; } public function getDebugContext(): array { return $this->debugContext; } } // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- function manageClientAppRoot(): string { $root = realpath((string) MANAGE_APP_ROOT); if ($root === false) { throw new RuntimeException('MANAGE_APP_ROOT existiert nicht: ' . MANAGE_APP_ROOT); } return rtrim($root, '/\\'); } function manageClientNormalizePath(string $path): string { return str_replace('\\', '/', $path); } function manageEnsureDir(string $dir): void { if (is_dir($dir)) { return; } // Mode 0775, deliberately without the setgid bit: asking for setgid on a // directory whose group the process does not belong to is refused outright // with EPERM, which is exactly the situation when Apache runs as www-data // under a project owned by a deploy user. Group inheritance comes from the // parent directory's own setgid bit or its default ACL instead. if (!mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException('Verzeichnis konnte nicht erstellt werden: ' . $dir); } // Defeats a restrictive umask, and keeps the ACL mask at rwx so that // entries granting another user write access stay effective. @chmod($dir, 0775); } function manageRemoveDir(string $dir): void { if (!is_dir($dir)) { return; } $items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { if ($item->isDir()) { @rmdir($item->getPathname()); } else { @unlink($item->getPathname()); } } @rmdir($dir); } function manageIsTemporaryFile(string $path): bool { $name = basename($path); return $name === '' || $name[0] === '.' || str_ends_with($name, '.tmp') || str_ends_with($name, '.part'); } function manageFormatBytes(int $bytes): string { if ($bytes >= 1073741824) { return number_format($bytes / 1073741824, 2, ',', '.') . ' GB'; } if ($bytes >= 1048576) { return number_format($bytes / 1048576, 2, ',', '.') . ' MB'; } if ($bytes >= 1024) { return number_format($bytes / 1024, 1, ',', '.') . ' KB'; } return $bytes . ' B'; } // --------------------------------------------------------------------------- // JSON state files // --------------------------------------------------------------------------- // Deliberately not readJsonFile()/writeJsonFile() from functions.php: those // return [] on failure without distinguishing, while the client needs the // atomic rename below so a crash mid-write can never truncate the backup index. function manageReadJson(string $file): array { if (!is_file($file)) { return []; } $content = file_get_contents($file); if ($content === false || trim($content) === '') { return []; } $decoded = json_decode($content, true); return is_array($decoded) ? $decoded : []; } function manageWriteJson(string $file, array $data): void { manageEnsureDir(dirname($file)); $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === false) { throw new RuntimeException('JSON konnte nicht kodiert werden: ' . basename($file)); } $tmpFile = $file . '.tmp'; if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) { throw new RuntimeException('Datei konnte nicht geschrieben werden: ' . basename($file)); } @chmod($tmpFile, 0664); if (!rename($tmpFile, $file)) { @unlink($tmpFile); throw new RuntimeException('Datei konnte nicht gespeichert werden: ' . basename($file)); } @chmod($file, 0664); } // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- // Appends one JSON line. Never throws: a failed log write must not abort an // update or a backup. function manageClientLog(string $level, string $message, array $context = []): void { $file = (string) MANAGE_LOG_FILE; if ($file === '') { return; } try { manageEnsureDir(dirname($file)); } catch (Throwable $exception) { return; } // Simple size cap; there is no log rotation in this shop. if (is_file($file) && (int) (filesize($file) ?: 0) > 2097152) { @rename($file, $file . '.1'); } $line = json_encode([ 'timestamp' => date('Y-m-d H:i:s'), 'level' => $level, 'message' => $message, 'context' => $context, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (is_string($line)) { @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX); } } // Truncated, whitespace-collapsed excerpt of a server response, for logging a // failed upload without dumping a whole HTML error page into the log. function manageResponseExcerpt($response): string { if (!is_string($response) || $response === '') { return ''; } $response = preg_replace('/\s+/', ' ', trim($response)); return is_string($response) ? substr($response, 0, 500) : ''; } // --------------------------------------------------------------------------- // Version file // --------------------------------------------------------------------------- function manageIsVersionString(string $version): bool { return preg_match('/^v\d+\.\d+\.\d+$/', trim($version)) === 1; } function manageVersionCompareValue(string $version): string { return ltrim(trim($version), 'vV'); } /** * Reads the installed version from includes/version.php. * * Returns '' when the version cannot be determined, which the callers treat as * "unknown" rather than as an error. */ function manageClientVersion(): string { $file = (string) MANAGE_VERSION_FILE; if ($file === '' || !is_file($file)) { return ''; } $constant = MANAGE_VERSION_CONSTANT; if (is_string($constant) && $constant !== '') { // The constant may already be defined in this process. if (defined($constant)) { $value = (string) constant($constant); if (manageIsVersionString($value)) { return trim($value); } } // Otherwise parse it out of the file without executing it: right after // an update the file on disk is newer than the loaded constant, and // including it twice would fatal on redefinition. $content = (string) file_get_contents($file); $pattern = '/define\s*\(\s*["\']' . preg_quote($constant, '/') . '["\']\s*,\s*["\'](v?\d+\.\d+\.\d+)["\']/'; if (preg_match($pattern, $content, $matches) === 1) { return trim($matches[1]); } return ''; } $value = trim((string) file_get_contents($file)); return manageIsVersionString($value) ? $value : ''; } // --------------------------------------------------------------------------- // HTTP transport // --------------------------------------------------------------------------- function manageClientConfigured(): bool { return trim((string) MANAGE_SERVER_URL) !== '' && trim((string) MANAGE_INSTANCE) !== '' && trim((string) MANAGE_TOKEN) !== ''; } function manageClientRequireConfigured(): void { if (manageClientConfigured()) { return; } throw new RuntimeException( 'Der Manage-Client ist nicht konfiguriert. MANAGE_SERVER_URL, MANAGE_INSTANCE und ' . 'MANAGE_TOKEN müssen in config.php gesetzt sein.' ); } function manageClientEndpoint(string $path): string { $base = rtrim(trim((string) MANAGE_SERVER_URL), '/'); return $base . '/api/v1/' . ltrim($path, '/'); } function manageClientUserAgent(): string { $version = manageClientVersion(); return 'Manage-Client/1.0 (' . (string) MANAGE_INSTANCE . '; app ' . ($version !== '' ? $version : 'unknown') . ')'; } function manageClientAuthHeaders(): string { return 'X-Manage-Instance: ' . (string) MANAGE_INSTANCE . "\r\n" . 'X-Manage-Token: ' . (string) MANAGE_TOKEN . "\r\n" . 'User-Agent: ' . manageClientUserAgent() . "\r\n"; } function manageClientStatusFromHeaders(array $headers): int { $status = 0; foreach ($headers as $header) { if (preg_match('/^HTTP\/\S+\s+(\d+)/', (string) $header, $matches) === 1) { $status = (int) $matches[1]; } } return $status; } function manageClientResponseHeaders($legacyHeaders): array { if (function_exists('http_get_last_response_headers')) { $lastHeaders = http_get_last_response_headers(); return is_array($lastHeaders) ? $lastHeaders : []; } return is_array($legacyHeaders) ? $legacyHeaders : []; } // Turns a server error response into a message worth reading. The API always // answers with {"success":false,"error":"..."}; anything else is truncated. function manageClientErrorMessage(int $status, $body): string { $suffix = $status > 0 ? ' (HTTP ' . $status . ')' : ''; if (is_string($body) && $body !== '') { $decoded = json_decode($body, true); if (is_array($decoded) && isset($decoded['error'])) { return trim((string) $decoded['error']) . $suffix; } $excerpt = substr(trim(preg_replace('/\s+/', ' ', $body) ?? ''), 0, 300); if ($excerpt !== '') { return $excerpt . $suffix; } } return 'Anfrage fehlgeschlagen' . ($suffix !== '' ? $suffix : ' (keine Antwort vom Server)') . '.'; } /** * Performs an authenticated request against the Manage server. * * Redirects are deliberately not followed, so credentials never travel to a * different destination than the configured one. * * @param string $method GET or POST * @param string $path endpoint below api/v1/ * @param string|null $body raw request body for POST * @param int|null $timeout seconds; defaults to MANAGE_HTTP_TIMEOUT * * @return array{status: int, body: string} */ function manageClientRequest( string $method, string $path, ?string $body = null, string $contentType = 'application/json', ?int $timeout = null ): array { manageClientRequireConfigured(); $url = manageClientEndpoint($path); if (!filter_var($url, FILTER_VALIDATE_URL)) { throw new RuntimeException('Ungültige Server-URL: ' . $url); } $headers = manageClientAuthHeaders() . "Accept: application/json\r\n"; $options = [ 'method' => $method, 'timeout' => $timeout ?? (int) MANAGE_HTTP_TIMEOUT, 'ignore_errors' => true, 'follow_location' => 0, 'protocol_version' => 1.1, ]; if ($body !== null) { $headers .= 'Content-Type: ' . $contentType . "\r\n"; $headers .= 'Content-Length: ' . strlen($body) . "\r\n"; $options['content'] = $body; } $options['header'] = $headers; $context = stream_context_create(['http' => $options]); $response = @file_get_contents($url, false, $context); $status = manageClientStatusFromHeaders(manageClientResponseHeaders($http_response_header ?? null)); if ($response === false && $status === 0) { throw new RuntimeException('Der Manage-Server ist nicht erreichbar: ' . $url); } return ['status' => $status, 'body' => is_string($response) ? $response : '']; } /** * Authenticated request that expects a JSON object and a 2xx status. */ function manageClientRequestJson( string $method, string $path, ?array $payload = null, ?int $timeout = null ): array { $body = $payload === null ? null : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($payload !== null && $body === false) { throw new RuntimeException('Anfrage konnte nicht kodiert werden.'); } $response = manageClientRequest($method, $path, $body, 'application/json', $timeout); if ($response['status'] < 200 || $response['status'] >= 300) { throw new RuntimeException(manageClientErrorMessage($response['status'], $response['body'])); } $decoded = json_decode($response['body'], true); if (!is_array($decoded)) { throw new RuntimeException('Die Antwort des Servers ist kein gültiges JSON.'); } return $decoded; } require_once __DIR__ . '/manage-zip.php'; require_once __DIR__ . '/manage-backup.php'; require_once __DIR__ . '/manage-update.php'; require_once __DIR__ . '/manage-heartbeat.php'; /** * Aggregate status for admin/settings.php. Never throws: every remote failure * is reported inside the returned array, so the settings page still renders * when the Manage server is unreachable. */ function manageClientStatus(): array { $status = [ 'instance' => (string) MANAGE_INSTANCE, 'server_url' => (string) MANAGE_SERVER_URL, 'configured' => manageClientConfigured(), 'version' => manageClientVersion(), 'php_version' => PHP_VERSION, 'update' => null, 'update_error' => null, 'backups' => [], 'last_backup_at' => null, 'pending_migrations' => [], 'errors' => [], ]; try { $status['backups'] = manageBackupList(); $status['last_backup_at'] = $status['backups'] === [] ? null : (string) ($status['backups'][0]['created_at'] ?? ''); } catch (Throwable $exception) { $status['errors'][] = $exception->getMessage(); } try { $status['pending_migrations'] = manageUpdatePendingMigrations(); } catch (Throwable $exception) { $status['errors'][] = $exception->getMessage(); } if ($status['configured']) { try { $status['update'] = manageUpdateCheck(); } catch (Throwable $exception) { $status['update_error'] = $exception->getMessage(); } } return $status; }