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" => "data/uploads" 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, static function (array $left, array $right): int { 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, static function (array $left, array $right): int { 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)); } 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("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, app_version */ function manageBackupUpload(string $archivePath, array $meta = []): array { manageClientRequireConfigured(); if (!is_file($archivePath)) { throw new RuntimeException("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("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" => manageRemoteResponseExcerpt($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" => manageRemoteResponseExcerpt($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, exactly like the extra remote targets. * * @param string $trigger manual | automatic | cron | update */ function manageBackupCreate(string $trigger = "manual"): array { $dir = manageBackupDir(); manageEnsureDir($dir); $lockHandle = fopen(manageBackupLockFile(), "c+"); if ($lockHandle === false) { throw new RuntimeException("Backup-Sperrdatei konnte nicht geöffnet werden."); } if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { fclose($lockHandle); throw new RuntimeException("Es läuft bereits ein Backup."); } $dumpFile = null; 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(); $database = null; if (manageDatabaseConfigured()) { $dumpFile = rtrim((string) MANAGE_WORK_DIR, "/\\") . DIRECTORY_SEPARATOR . "dump-" . date("Ymd-His") . "-" . bin2hex(random_bytes(4)) . ".sql"; $database = manageDatabaseDump($dumpFile); $files[] = [ "path" => $dumpFile, "name" => "database/" . $database["database"] . ".sql", ]; } $zipStats = manageZipWrite($tmpFile, $files); if (!rename($tmpFile, $archivePath)) { @unlink($tmpFile); throw new RuntimeException("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, ]); } } $uploads = array_merge($uploads, manageRemoteUploadAll($archivePath, $metadata)); $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(), "database" => $database, "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 { if ($dumpFile !== null && is_file($dumpFile)) { @unlink($dumpFile); } flock($lockHandle, LOCK_UN); fclose($lockHandle); } } // --------------------------------------------------------------------------- // Automatic scheduling for hosts without cron // --------------------------------------------------------------------------- function manageBackupLastAutomaticAt(): int { foreach (manageBackupList() as $record) { $trigger = (string) ($record["trigger"] ?? ""); if ($trigger !== "automatic" && $trigger !== "cron") { 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 when the interval has elapsed. Meant to be called * from an admin page the host application loads regularly. Returns null when * nothing was due. */ function manageBackupCreateAutomaticIfDue(): ?array { if (!manageBackupIsAutomaticDue()) { return null; } return manageBackupCreate("automatic"); }