isFile() || !$item->isReadable()) { continue; } $path = $item->getPathname(); if (manageIsTemporaryFile($path)) { continue; } $relative = ltrim(manageClientNormalizePath(substr($path, strlen($base))), '/'); if ($relative === '' || str_contains($relative, "\0")) { continue; } $files[] = [ 'path' => $path, 'name' => trim($entryPrefix . '/' . $relative, '/'), ]; } } /** * Resolves MANAGE_BACKUP_SOURCES into a flat list of archive entries. * * Each source entry supports one of: * 'glob' => 'data/*.json' non-recursive shell glob * 'dir' => 'assets/images' recursive directory * 'file' => 'settings.ini' single file * plus an optional 'as' prefix for the path inside the archive. */ function manageBackupCollectSources(): array { $root = manageClientAppRoot(); $sources = is_array(MANAGE_BACKUP_SOURCES) ? MANAGE_BACKUP_SOURCES : []; $files = []; foreach ($sources as $source) { if (!is_array($source)) { continue; } $prefix = trim((string) ($source['as'] ?? ''), '/'); if (isset($source['glob'])) { $pattern = $root . DIRECTORY_SEPARATOR . ltrim((string) $source['glob'], '/\\'); foreach (glob($pattern) ?: [] as $path) { if (!is_file($path) || !is_readable($path) || manageIsTemporaryFile($path)) { continue; } $files[] = [ 'path' => $path, 'name' => trim($prefix . '/' . basename($path), '/'), ]; } continue; } if (isset($source['dir'])) { $dir = $root . DIRECTORY_SEPARATOR . ltrim((string) $source['dir'], '/\\'); manageBackupCollectDirectory($dir, $prefix !== '' ? $prefix : basename($dir), $files); continue; } if (isset($source['file'])) { $path = $root . DIRECTORY_SEPARATOR . ltrim((string) $source['file'], '/\\'); if (is_file($path) && is_readable($path)) { $files[] = [ 'path' => $path, 'name' => trim($prefix . '/' . basename($path), '/'), ]; } } } // Two sources may resolve to the same archive entry; the first one wins so // the ZIP can never contain a duplicate name. $unique = []; foreach ($files as $file) { $unique[$file['name']] = $file; } $files = array_values($unique); usort($files, function ($left, $right) { return strcmp($left['name'], $right['name']); }); return $files; } // --------------------------------------------------------------------------- // Index // --------------------------------------------------------------------------- function manageBackupReadIndex(): array { $index = manageReadJson(manageBackupIndexFile()); $records = isset($index['backups']) && is_array($index['backups']) ? $index['backups'] : []; return ['backups' => array_values($records)]; } function manageBackupWriteIndex(array $records): void { manageWriteJson(manageBackupIndexFile(), ['backups' => array_values($records)]); } /** * Local backups, newest first. Self-healing: index records whose file is gone * are dropped and sizes are refreshed from disk. */ function manageBackupList(): array { $dir = manageBackupDir(); $existing = []; foreach (manageBackupReadIndex()['backups'] as $record) { if (!is_array($record)) { continue; } $filename = basename((string) ($record['filename'] ?? '')); if ($filename === '' || !is_file($dir . $filename)) { continue; } $record['filename'] = $filename; $record['size'] = (int) (filesize($dir . $filename) ?: ($record['size'] ?? 0)); $existing[] = $record; } usort($existing, function ($left, $right) { return strcmp((string) ($right['created_at'] ?? ''), (string) ($left['created_at'] ?? '')); }); return $existing; } function manageBackupRetentionLimit(): int { return max(1, (int) MANAGE_BACKUP_LOCAL_RETENTION); } function manageBackupApplyRetention(): void { $records = manageBackupList(); $keep = manageBackupRetentionLimit(); $dir = manageBackupDir(); foreach (array_slice($records, $keep) as $record) { $filename = basename((string) ($record['filename'] ?? '')); if ($filename !== '' && is_file($dir . $filename)) { @unlink($dir . $filename); } } manageBackupWriteIndex(array_slice(manageBackupList(), 0, $keep)); } // Absolute path of a local archive. Validates the filename strictly, so the // download form on the settings page cannot be made to serve another path. function manageBackupPath(string $filename): string { $filename = basename($filename); if (preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $filename) !== 1) { throw new RuntimeException('Ungültiger Backup-Dateiname: ' . $filename); } $path = manageBackupDir() . $filename; if (!is_file($path)) { throw new RuntimeException('Backup wurde nicht gefunden: ' . $filename); } return $path; } // --------------------------------------------------------------------------- // Upload to the Manage server // --------------------------------------------------------------------------- function manageBackupBuildMultipartBody( array $fields, string $fileField, string $filePath, string $fileName, string $boundary ): string { $body = ''; foreach ($fields as $name => $value) { $body .= '--' . $boundary . "\r\n"; $body .= 'Content-Disposition: form-data; name="' . addcslashes((string) $name, "\"\\") . "\"\r\n\r\n"; $body .= (string) $value . "\r\n"; } $payload = file_get_contents($filePath); if ($payload === false) { throw new RuntimeException('Das Backup-ZIP konnte für den Upload nicht gelesen werden.'); } $body .= '--' . $boundary . "\r\n"; $body .= 'Content-Disposition: form-data; name="' . addcslashes($fileField, "\"\\") . '"; filename="' . addcslashes($fileName, "\"\\") . "\"\r\n"; $body .= "Content-Type: application/zip\r\n\r\n"; $body .= $payload . "\r\n"; $body .= '--' . $boundary . "--\r\n"; return $body; } /** * Uploads one archive to the Manage server. * * @param array $meta trigger, file_count, source_bytes, sha256 */ function manageBackupUpload(string $archivePath, array $meta = []): array { manageClientRequireConfigured(); if (!is_file($archivePath)) { throw new RuntimeException('Die Backup-Datei existiert nicht: ' . $archivePath); } $filename = basename($archivePath); $sha256 = strtolower(trim((string) ($meta['sha256'] ?? ''))); if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) { $sha256 = strtolower(hash_file('sha256', $archivePath) ?: ''); } if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) { throw new RuntimeException('Die Prüfsumme des Backups konnte nicht berechnet werden.'); } $metaPayload = json_encode([ 'trigger' => (string) ($meta['trigger'] ?? 'manual'), 'file_count' => (int) ($meta['file_count'] ?? 0), 'source_bytes' => (int) ($meta['source_bytes'] ?? 0), 'app_version' => manageClientVersion(), ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $boundary = '----manage-client-' . bin2hex(random_bytes(12)); $body = manageBackupBuildMultipartBody( [ 'filename' => $filename, 'sha256' => $sha256, 'meta' => $metaPayload === false ? '{}' : $metaPayload, ], 'backup', $archivePath, $filename, $boundary ); $response = manageClientRequest( 'POST', 'backup.php', $body, 'multipart/form-data; boundary=' . $boundary, (int) MANAGE_HTTP_TIMEOUT_LONG ); if ($response['status'] < 200 || $response['status'] >= 300) { throw new ManageRemoteUploadException( manageClientErrorMessage($response['status'], $response['body']), [ 'http_status' => $response['status'], 'response_excerpt' => manageResponseExcerpt($response['body']), 'filename' => $filename, ] ); } $decoded = json_decode($response['body'], true); if (!is_array($decoded) || empty($decoded['success'])) { $error = is_array($decoded) ? trim((string) ($decoded['error'] ?? '')) : ''; throw new ManageRemoteUploadException( 'Der Manage-Server hat das Backup abgelehnt' . ($error !== '' ? ': ' . $error : '.'), [ 'http_status' => $response['status'], 'response_excerpt' => manageResponseExcerpt($response['body']), 'filename' => $filename, ] ); } return [ 'target' => 'Manage-Server', 'type' => 'manage', 'success' => true, 'uploaded_at' => date(DATE_ATOM), 'server_filename' => (string) ($decoded['filename'] ?? ''), 'remote_path' => manageClientEndpoint('backup.php'), ]; } // --------------------------------------------------------------------------- // Creating a backup // --------------------------------------------------------------------------- /** * Creates a local archive and, unless disabled, uploads it. * * A failed upload never invalidates the local archive: the error is stored in * the index record and logged, and the function returns normally. If it does * throw, the archive itself never came into being. * * @param string $trigger manual | automatic | update */ function manageBackupCreate(string $trigger = 'manual'): array { $dir = manageBackupDir(); manageEnsureDir($dir); $lockHandle = fopen(manageBackupLockFile(), 'c+'); if ($lockHandle === false) { throw new RuntimeException('Die Backup-Sperrdatei konnte nicht geöffnet werden.'); } // Two admins pressing the button at the same time must not interleave. if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { fclose($lockHandle); throw new RuntimeException('Es läuft bereits ein Backup.'); } try { $baseName = 'backup-' . date('Ymd-His'); $filename = $baseName . '.zip'; $counter = 2; while (file_exists($dir . $filename)) { $filename = $baseName . '-' . $counter . '.zip'; $counter++; } $tmpFile = $dir . '.' . $filename . '.tmp'; $archivePath = $dir . $filename; $createdAt = date(DATE_ATOM); $files = manageBackupCollectSources(); $zipStats = manageZipWrite($tmpFile, $files); if (!rename($tmpFile, $archivePath)) { @unlink($tmpFile); throw new RuntimeException('Das Backup-ZIP konnte nicht finalisiert werden.'); } @chmod($archivePath, 0660); $metadata = [ 'filename' => $filename, 'created_at' => $createdAt, 'trigger' => $trigger, 'sha256' => $zipStats['sha256'], 'file_count' => $zipStats['file_count'], 'source_bytes' => $zipStats['source_bytes'], ]; $uploads = []; if (MANAGE_BACKUP_UPLOAD === true && manageClientConfigured()) { try { $uploads[] = manageBackupUpload($archivePath, $metadata); } catch (Throwable $exception) { $debugContext = $exception instanceof ManageRemoteUploadException ? $exception->getDebugContext() : []; $uploads[] = [ 'target' => 'Manage-Server', 'type' => 'manage', 'success' => false, 'error' => $exception->getMessage(), 'debug' => $debugContext, ]; manageClientLog('ERROR', 'Backup upload to manage server failed', [ 'filename' => $filename, 'error' => $exception->getMessage(), 'debug' => $debugContext, ]); } } $record = [ 'filename' => $filename, 'created_at' => $createdAt, 'trigger' => $trigger, 'size' => (int) (filesize($archivePath) ?: $zipStats['archive_bytes']), 'file_count' => $zipStats['file_count'], 'source_bytes' => $zipStats['source_bytes'], 'sha256' => $zipStats['sha256'], 'app_version' => manageClientVersion(), 'remote_uploads' => $uploads, ]; $records = manageBackupList(); array_unshift($records, $record); manageBackupWriteIndex($records); manageBackupApplyRetention(); manageClientLog('INFO', 'Backup created', [ 'filename' => $filename, 'trigger' => $trigger, 'file_count' => $record['file_count'], 'size' => $record['size'], ]); return $record; } catch (Throwable $exception) { manageClientLog('ERROR', 'Backup failed', [ 'trigger' => $trigger, 'error' => $exception->getMessage(), ]); throw $exception; } finally { flock($lockHandle, LOCK_UN); fclose($lockHandle); } } // --------------------------------------------------------------------------- // Automatic scheduling — this host has no cron // --------------------------------------------------------------------------- function manageBackupLastAutomaticAt(): int { foreach (manageBackupList() as $record) { if ((string) ($record['trigger'] ?? '') !== 'automatic') { continue; } $timestamp = strtotime((string) ($record['created_at'] ?? '')); if ($timestamp !== false) { return $timestamp; } } return 0; } function manageBackupIsAutomaticDue(): bool { $interval = (int) MANAGE_BACKUP_AUTO_INTERVAL_SECONDS; if ($interval < 1) { return false; } return time() - manageBackupLastAutomaticAt() >= $interval; } /** * Creates an automatic backup once the interval has elapsed, otherwise returns * null immediately. Called from admin/index.php, which is the rarely loaded * page this is meant for — a backup takes a few seconds. */ function manageBackupCreateAutomaticIfDue(): ?array { if (!manageBackupIsAutomaticDue()) { return null; } return manageBackupCreate('automatic'); }