| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- <?php
- declare(strict_types=1);
- // Release storage and manifest handling.
- //
- // Two deliberate design decisions:
- // - the package filename prefix is configurable (MANAGE_PACKAGE_PREFIX)
- // - the package download URL is built from MANAGE_PUBLIC_URL instead of the
- // client-controlled Host header
- require_once __DIR__ . "/bootstrap.php";
- function manageReleaseReadManifest(): array
- {
- $file = manageManifestFile();
- if (!is_file($file)) {
- return ["latest" => "", "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;
- }
|