| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- <?php
- declare(strict_types=1);
- // Client-side backup: collecting sources, writing the archive, local retention
- // and uploading to the manage server.
- //
- // Ported from the PSA order system (includes/backup.php). The one structural
- // change is that the source list is no longer hardcoded to data/*.json plus
- // data/uploads/**: it comes from MANAGE_BACKUP_SOURCES, plus an optional SQL
- // dump when MANAGE_BACKUP_DATABASE is configured.
- 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" => "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");
- }
|