| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564 |
- <?php
- // Manage client core: configuration defaults, filesystem helpers, the
- // authenticated HTTP transport and version file handling.
- //
- // This is the only file the shop needs to require; it pulls in the rest of the
- // client. config.php is expected to be loaded already (every entry point of the
- // shop does that first), so the MANAGE_* constants set there win over the
- // defaults below.
- //
- // require_once __DIR__ . '/../includes/manage.php';
- // $status = manageClientStatus();
- //
- // Talks to the Manage server at MANAGE_SERVER_URL: update manifest, release
- // package download, backup upload and heartbeat. See docs/BACKUP_UPDATE.md.
- declare(strict_types=1);
- if (!defined('MANAGE_SERVER_URL')) {
- define('MANAGE_SERVER_URL', '');
- }
- if (!defined('MANAGE_INSTANCE')) {
- define('MANAGE_INSTANCE', '');
- }
- if (!defined('MANAGE_TOKEN')) {
- define('MANAGE_TOKEN', '');
- }
- if (!defined('MANAGE_HTTP_TIMEOUT')) {
- define('MANAGE_HTTP_TIMEOUT', 15);
- }
- if (!defined('MANAGE_HTTP_TIMEOUT_LONG')) {
- define('MANAGE_HTTP_TIMEOUT_LONG', 300);
- }
- if (!defined('MANAGE_APP_ROOT')) {
- define('MANAGE_APP_ROOT', dirname(__DIR__));
- }
- if (!defined('MANAGE_VERSION_FILE')) {
- define('MANAGE_VERSION_FILE', MANAGE_APP_ROOT . '/includes/version.php');
- }
- if (!defined('MANAGE_VERSION_CONSTANT')) {
- define('MANAGE_VERSION_CONSTANT', 'APP_VERSION');
- }
- if (!defined('MANAGE_DIR')) {
- define('MANAGE_DIR', MANAGE_APP_ROOT . '/data/manage/');
- }
- if (!defined('MANAGE_WORK_DIR')) {
- define('MANAGE_WORK_DIR', MANAGE_DIR . 'work/');
- }
- if (!defined('MANAGE_UPDATE_BACKUP_DIR')) {
- define('MANAGE_UPDATE_BACKUP_DIR', MANAGE_DIR . 'updates/');
- }
- if (!defined('MANAGE_BACKUP_DIR')) {
- define('MANAGE_BACKUP_DIR', MANAGE_DIR . 'backups/');
- }
- if (!defined('MANAGE_LOG_FILE')) {
- define('MANAGE_LOG_FILE', MANAGE_DIR . 'manage-client.log');
- }
- if (!defined('MANAGE_HEARTBEAT_STATE')) {
- define('MANAGE_HEARTBEAT_STATE', MANAGE_DIR . 'heartbeat.json');
- }
- if (!defined('MANAGE_UPDATE_PROTECTED_PATHS')) {
- define('MANAGE_UPDATE_PROTECTED_PATHS', ['config.php', 'data/', '.git/']);
- }
- if (!defined('MANAGE_UPDATE_SANITY_PATHS')) {
- define('MANAGE_UPDATE_SANITY_PATHS', ['admin/index.php', 'includes/functions.php']);
- }
- if (!defined('MANAGE_UPDATE_POST_HOOK')) {
- define('MANAGE_UPDATE_POST_HOOK', null);
- }
- if (!defined('MANAGE_MIGRATIONS_DIR')) {
- define('MANAGE_MIGRATIONS_DIR', MANAGE_APP_ROOT . '/migrations');
- }
- if (!defined('MANAGE_MIGRATIONS_STATE')) {
- define('MANAGE_MIGRATIONS_STATE', MANAGE_DIR . 'migrations.json');
- }
- if (!defined('MANAGE_BACKUP_SOURCES')) {
- define('MANAGE_BACKUP_SOURCES', [['as' => '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;
- }
|