120 || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) !== 1 ) { throw new RuntimeException("Invalid instance identifier."); } return $instance; } function backupUploadReadJsonFile(string $file): array { if (!is_file($file)) { return []; } $decoded = json_decode((string) file_get_contents($file), true); return is_array($decoded) ? $decoded : []; } function backupUploadWriteJsonFile(string $file, array $data): void { backupUploadEnsureDirectory(dirname($file)); $json = json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, ); if ($json === false) { throw new RuntimeException("JSON cannot be encoded."); } $tmpFile = $file . ".tmp"; if (file_put_contents($tmpFile, $json . PHP_EOL, LOCK_EX) === false) { throw new RuntimeException("JSON cannot be written."); } @chmod($tmpFile, 0664); if (!rename($tmpFile, $file)) { @unlink($tmpFile); throw new RuntimeException("JSON cannot be saved."); } @chmod($file, 0664); } function backupUploadReadIndex(): array { $index = backupUploadReadJsonFile((string) BACKUP_SERVER_INDEX_FILE); $backups = isset($index["backups"]) && is_array($index["backups"]) ? $index["backups"] : []; return ["backups" => array_values($backups)]; } function backupUploadWriteIndex(array $backups): void { backupUploadWriteJsonFile((string) BACKUP_SERVER_INDEX_FILE, [ "backups" => array_values($backups), ]); } function backupUploadGetRetention(): int { $settings = backupUploadReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE); if (isset($settings["retention"])) { return max(1, (int) $settings["retention"]); } return max(1, (int) BACKUP_SERVER_RETENTION); } function backupUploadGetAllowedInstances(): array { $settings = backupUploadReadJsonFile((string) BACKUP_SERVER_SETTINGS_FILE); $instances = isset($settings["instances"]) && is_array($settings["instances"]) ? $settings["instances"] : []; $allowed = []; foreach ($instances as $instance) { $instance = (string) $instance; if ( $instance !== "" && preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $instance) === 1 ) { $allowed[$instance] = true; } } return $allowed; } function backupUploadInstanceIsAllowed(string $instance): bool { $allowed = backupUploadGetAllowedInstances(); return isset($allowed[$instance]); } function backupUploadInstanceDir(string $instance): string { return rtrim((string) BACKUP_SERVER_BACKUP_DIR, "/\\") . DIRECTORY_SEPARATOR . $instance; } function backupUploadIsZipFile(string $path): bool { $handle = fopen($path, "rb"); if ($handle === false) { return false; } $signature = fread($handle, 4); fclose($handle); return $signature === "PK\x03\x04" || $signature === "PK\x05\x06" || $signature === "PK\x07\x08"; } function backupUploadChooseFilename(string $clientFilename, string $instanceDir): string { $clientFilename = trim($clientFilename); if ($clientFilename === "") { $filename = "backup-" . gmdate("Ymd-His") . ".zip"; } elseif ( basename($clientFilename) !== $clientFilename || preg_match('/^backup-\d{8}-\d{6}(?:-\d+)?\.zip$/', $clientFilename) !== 1 ) { throw new RuntimeException("Invalid backup filename."); } else { $filename = $clientFilename; } $base = substr($filename, 0, -4); $counter = 2; while (is_file($instanceDir . DIRECTORY_SEPARATOR . $filename)) { $filename = $base . "-" . $counter . ".zip"; $counter++; } return $filename; } function backupUploadApplyRetention(string $instance): void { $index = backupUploadReadIndex(); $retention = backupUploadGetRetention(); $instanceBackups = []; $otherBackups = []; foreach ($index["backups"] as $backup) { if (!is_array($backup)) { continue; } if (($backup["instance"] ?? "") === $instance) { $instanceBackups[] = $backup; } else { $otherBackups[] = $backup; } } usort($instanceBackups, function ($left, $right) { return strcmp((string) ($right["uploaded_at"] ?? ""), (string) ($left["uploaded_at"] ?? "")); }); $keep = array_slice($instanceBackups, 0, $retention); $remove = array_slice($instanceBackups, $retention); $instanceDir = backupUploadInstanceDir($instance); foreach ($remove as $backup) { $filename = basename((string) ($backup["filename"] ?? "")); if ($filename !== "" && is_file($instanceDir . DIRECTORY_SEPARATOR . $filename)) { @unlink($instanceDir . DIRECTORY_SEPARATOR . $filename); } } backupUploadWriteIndex(array_merge($otherBackups, $keep)); } if ($_SERVER["REQUEST_METHOD"] !== "POST") { backupUploadRespond(405, ["success" => false, "error" => "POST required."]); } try { $instance = backupUploadNormalizeInstance((string) ($_POST["instance"] ?? "")); if (!backupUploadInstanceIsAllowed($instance)) { throw new RuntimeException("Instance is not allowed."); } $file = $_FILES["backup"] ?? null; if (!is_array($file)) { throw new RuntimeException("Backup file is missing."); } if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new RuntimeException("Upload failed with error code " . (string) ($file["error"] ?? "unknown") . "."); } $tmpName = (string) ($file["tmp_name"] ?? ""); if ($tmpName === "" || !is_uploaded_file($tmpName)) { throw new RuntimeException("Upload is invalid."); } if (!backupUploadIsZipFile($tmpName)) { throw new RuntimeException("Uploaded file must be a ZIP file."); } $instanceDir = backupUploadInstanceDir($instance); backupUploadEnsureDirectory($instanceDir); $requestedFilename = (string) ($_POST["filename"] ?? ""); $clientFilename = $requestedFilename !== "" ? $requestedFilename : (string) ($file["name"] ?? ""); $filename = backupUploadChooseFilename($requestedFilename, $instanceDir); $targetPath = $instanceDir . DIRECTORY_SEPARATOR . $filename; if (!move_uploaded_file($tmpName, $targetPath)) { throw new RuntimeException("Uploaded backup cannot be stored."); } @chmod($targetPath, 0664); $size = filesize($targetPath); $sha256 = strtolower(hash_file("sha256", $targetPath) ?: ""); if ($size === false || $size <= 0 || !preg_match('/^[a-f0-9]{64}$/', $sha256)) { @unlink($targetPath); throw new RuntimeException("Stored backup could not be verified."); } $postedSha256 = strtolower(trim((string) ($_POST["sha256"] ?? ""))); if ($postedSha256 !== "" && (!preg_match('/^[a-f0-9]{64}$/', $postedSha256) || $postedSha256 !== $sha256)) { @unlink($targetPath); throw new RuntimeException("Backup checksum mismatch."); } $index = backupUploadReadIndex(); $record = [ "instance" => $instance, "filename" => $filename, "client_filename" => basename($clientFilename), "size" => $size, "sha256" => $sha256, "uploaded_at" => date(DATE_ATOM), "source_ip" => $_SERVER["REMOTE_ADDR"] ?? "unknown", ]; $index["backups"][] = $record; backupUploadWriteIndex($index["backups"]); backupUploadApplyRetention($instance); backupUploadRespond(200, [ "success" => true, "instance" => $instance, "filename" => $filename, "size" => $size, "sha256" => $sha256, "retention" => backupUploadGetRetention(), ]); } catch (Throwable $exception) { backupUploadRespond(400, [ "success" => false, "error" => $exception->getMessage(), ]); }