"", "releases" => []]; } $decoded = manageReadJsonFile($file); return [ "latest" => trim((string) ($decoded["latest"] ?? "")), "releases" => isset($decoded["releases"]) && is_array($decoded["releases"]) ? $decoded["releases"] : [], ]; } function manageReleaseWriteManifest(array $manifest): void { manageEnsureDirectory(manageReleasesDir()); manageWriteJsonFile(manageManifestFile(), [ "latest" => (string) ($manifest["latest"] ?? ""), "releases" => isset($manifest["releases"]) && is_array($manifest["releases"]) ? $manifest["releases"] : [], ]); } function manageReleasePackageFileName(string $version): string { $prefix = trim((string) MANAGE_PACKAGE_PREFIX); if ($prefix === "") { $prefix = "release"; } return $prefix . "-" . $version . ".zip"; } function manageReleasePackagePath(string $packageRelativePath): ?string { $packageRelativePath = trim($packageRelativePath); if ( $packageRelativePath === "" || str_contains($packageRelativePath, "\0") || str_starts_with($packageRelativePath, "/") ) { return null; } $packagePath = realpath(manageReleasesDir() . $packageRelativePath); $packagesDir = realpath(managePackagesDir()); if ( $packagePath === false || $packagesDir === false || !str_starts_with($packagePath, rtrim($packagesDir, "/\\") . DIRECTORY_SEPARATOR) || !is_file($packagePath) ) { return null; } return $packagePath; } // Absolute download URL handed to clients. Derived from the configured public // URL so a forged Host header cannot redirect a client to another server. function manageReleasePackageUrl(string $version): string { $base = rtrim(trim((string) MANAGE_PUBLIC_URL), "/"); if ($base === "") { throw new RuntimeException( "MANAGE_PUBLIC_URL ist nicht konfiguriert. Ohne diesen Wert kann keine Paket-URL erzeugt werden.", ); } return $base . "/api/v1/package.php?version=" . rawurlencode($version); } function manageReleaseUploadedFileIsZip(array $file): bool { $name = strtolower((string) ($file["name"] ?? "")); $tmpName = (string) ($file["tmp_name"] ?? ""); if (!str_ends_with($name, ".zip") || !is_uploaded_file($tmpName)) { return false; } return manageFileIsZip($tmpName); } // Stores an uploaded package and publishes it as the new latest release. // The checksum and size are always computed server-side; the publisher never // supplies them. function manageReleasePublishUpload(string $version, array $file): array { $version = trim($version); if (!manageIsVersion($version)) { throw new RuntimeException("Version muss dem Format vX.Y.Z entsprechen."); } if (($file["error"] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new RuntimeException( "Upload fehlgeschlagen (Fehlercode " . (string) ($file["error"] ?? "unbekannt") . ").", ); } if (!manageReleaseUploadedFileIsZip($file)) { throw new RuntimeException("Die hochgeladene Datei muss ein ZIP-Paket sein."); } manageEnsureDirectory(managePackagesDir()); $fileName = manageReleasePackageFileName($version); $targetPath = managePackagesDir() . $fileName; if (!move_uploaded_file((string) $file["tmp_name"], $targetPath)) { throw new RuntimeException("Das Paket konnte nicht gespeichert werden."); } @chmod($targetPath, 0664); $sha256 = strtolower(hash_file("sha256", $targetPath) ?: ""); $size = filesize($targetPath); if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1 || $size === false || $size <= 0) { @unlink($targetPath); throw new RuntimeException("Das gespeicherte Paket konnte nicht verifiziert werden."); } $manifest = manageReleaseReadManifest(); $manifest["latest"] = $version; $manifest["releases"][$version] = [ "version" => $version, "package" => "packages/" . $fileName, "sha256" => $sha256, "size" => $size, "published_at" => date(DATE_ATOM), ]; uksort($manifest["releases"], static function (string $left, string $right): int { return version_compare(ltrim($left, "vV"), ltrim($right, "vV")); }); manageReleaseWriteManifest($manifest); manageLogAccess("Release published", [ "version" => $version, "size" => $size, "sha256" => $sha256, ]); return $manifest["releases"][$version]; } function manageReleaseSetLatest(string $version): void { $version = trim($version); if (!manageIsVersion($version)) { throw new RuntimeException("Ungültige Release-Version."); } $manifest = manageReleaseReadManifest(); if (!isset($manifest["releases"][$version])) { throw new RuntimeException("Dieses Release ist nicht im Manifest vorhanden."); } $manifest["latest"] = $version; manageReleaseWriteManifest($manifest); manageLogAccess("Latest release changed", ["version" => $version]); } function manageReleaseDelete(string $version): void { $version = trim($version); if (!manageIsVersion($version)) { throw new RuntimeException("Ungültige Release-Version."); } $manifest = manageReleaseReadManifest(); if (!isset($manifest["releases"][$version])) { throw new RuntimeException("Dieses Release ist nicht im Manifest vorhanden."); } $package = trim((string) ($manifest["releases"][$version]["package"] ?? "")); unset($manifest["releases"][$version]); if ($manifest["latest"] === $version) { $manifest["latest"] = ""; } manageReleaseWriteManifest($manifest); $packagePath = manageReleasePackagePath($package); if ($packagePath !== null) { @unlink($packagePath); } manageLogAccess("Release deleted", ["version" => $version]); } /** * Resolves one release into the payload a client receives. Every field is * re-validated here, because the manifest is the only thing standing between a * client and the code it will execute. * * @return array{version: string, package_url: string, sha256: string, size: int, published_at: string} */ function manageReleaseResolve(string $version): array { $manifest = manageReleaseReadManifest(); $releases = $manifest["releases"]; if (!isset($releases[$version]) || !is_array($releases[$version])) { throw new RuntimeException("Release ist nicht konfiguriert."); } $release = $releases[$version]; $releaseVersion = trim((string) ($release["version"] ?? $version)); $sha256 = strtolower(trim((string) ($release["sha256"] ?? ""))); $package = trim((string) ($release["package"] ?? "")); $size = isset($release["size"]) ? (int) $release["size"] : 0; $publishedAt = trim((string) ($release["published_at"] ?? "")); if ($releaseVersion !== $version || !manageIsVersion($releaseVersion)) { throw new RuntimeException("Release-Version ist ungültig."); } if (preg_match('/^[a-f0-9]{64}$/', $sha256) !== 1) { throw new RuntimeException("Release-Prüfsumme ist ungültig."); } $packagePath = manageReleasePackagePath($package); if ($packagePath === null) { throw new RuntimeException("Release-Paket fehlt."); } if (strtolower(pathinfo($packagePath, PATHINFO_EXTENSION)) !== "zip") { throw new RuntimeException("Release-Paket ist keine ZIP-Datei."); } if ($size <= 0) { $size = (int) (filesize($packagePath) ?: 0); } return [ "version" => $releaseVersion, "package_path" => $packagePath, "package_url" => manageReleasePackageUrl($releaseVersion), "sha256" => $sha256, "size" => $size, "published_at" => $publishedAt, ]; } function manageReleaseLatestVersion(): string { $latest = manageReleaseReadManifest()["latest"]; return manageIsVersion($latest) ? $latest : ""; } // Releases newest first, for the admin UI. function manageReleaseListDescending(): array { $releases = manageReleaseReadManifest()["releases"]; uksort($releases, static function (string $left, string $right): int { return version_compare(ltrim($right, "vV"), ltrim($left, "vV")); }); return $releases; }