| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- <?php
- // Backups: collecting sources, writing the archive, local retention and the
- // upload to the Manage server.
- //
- // The source list is not hardcoded — it comes from MANAGE_BACKUP_SOURCES in
- // config.php, which for this shop is data/*.json plus assets/images/.
- //
- // There is deliberately no restore: archives are created and transferred, never
- // played back. Restoring is manual work, see docs/BACKUP_UPDATE.md.
- declare(strict_types=1);
- function manageBackupDir(): string
- {
- return rtrim((string) MANAGE_BACKUP_DIR, '/\\') . DIRECTORY_SEPARATOR;
- }
- function manageBackupIndexFile(): string
- {
- return manageBackupDir() . 'backup-index.json';
- }
- function manageBackupLockFile(): string
- {
- return manageBackupDir() . '.backup.lock';
- }
- // ---------------------------------------------------------------------------
- // Source collection
- // ---------------------------------------------------------------------------
- // Recursively lists readable files below $dir, mapped to $entryPrefix.
- function manageBackupCollectDirectory(string $dir, string $entryPrefix, array &$files): void
- {
- if (!is_dir($dir)) {
- return;
- }
- $base = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR;
- $items = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::LEAVES_ONLY
- );
- foreach ($items as $item) {
- if (!$item->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');
- }
|