// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package handlers import ( "encoding/json" "fmt" "net/http" "os" "sort" "strings" "time" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" "golang.org/x/mod/semver" ) func GetRegistry(w http.ResponseWriter, r *http.Request) { // 1. Query Gitea REST API using the exact Org name apiURL := fmt.Sprintf("https://git.dcntrlized.org/api/v1/packages/%s?type=container", os.Getenv("GITEA_ORG")) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, apiURL, nil) if err != nil { logger.Error("Failed to create request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } logger.Debug("Querying Gitea package API at %s", apiURL) req.Header.Set("Authorization", "token "+os.Getenv("GITEA_RW_PACKAGE_TOKEN")) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { logger.Error("Failed to reach Gitea package API: %v", err) http.Error(w, "Failed to connect to upstream registry", http.StatusBadGateway) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { logger.Error("Registry API returned non-200 status code: %d", resp.StatusCode) http.Error(w, fmt.Sprintf("Registry API error: %s", resp.Status), resp.StatusCode) return } var rawPackages []types.RawPackage if err := json.NewDecoder(resp.Body).Decode(&rawPackages); err != nil { logger.Error("Failed to decode registry response: %v", err) http.Error(w, "Failed to decode registry response", http.StatusInternalServerError) return } // 2. Group by Name and filter out "latest" & "sha256:" groupedPackages := make(map[string][]types.RawPackage) for _, pkg := range rawPackages { if pkg.Version == "latest" || strings.HasPrefix(pkg.Version, "sha256:") { continue } groupedPackages[pkg.Name] = append(groupedPackages[pkg.Name], pkg) } // normalizeSemver expands partial version tags to the full X.Y.Z form, // preserving an optional pre-release suffix (v3 -> 3.0.0, v3.1 -> 3.1.0, // v3.3.3-hotfix -> 3.3.3-hotfix). normalizeSemver := func(v string) string { tag := strings.TrimPrefix(v, "v") prerelease := "" if i := strings.IndexByte(tag, '-'); i >= 0 { prerelease = tag[i:] tag = tag[:i] } parts := strings.Split(tag, ".") for len(parts) < 3 { parts = append(parts, "0") } return strings.Join(parts, ".") + prerelease } // semverSpecificity counts the dot-separated segments of a version's // major.minor.patch part, so that among versions that normalize equal the most // complete one wins (3.4.0 over 3.4). semverSpecificity := func(v string) int { tag := strings.TrimPrefix(v, "v") if i := strings.IndexByte(tag, '-'); i >= 0 { tag = tag[:i] } return strings.Count(tag, ".") } // 3. For each container name, find the package entry with the highest semver var latestPackages []types.RawPackage for _, pkgs := range groupedPackages { if len(pkgs) == 0 { continue } sort.Slice(pkgs, func(i, j int) bool { nI := normalizeSemver(pkgs[i].Version) nJ := normalizeSemver(pkgs[j].Version) vI := "v" + nI vJ := "v" + nJ // Compare using semver package if semver.IsValid(vI) && semver.IsValid(vJ) { if c := semver.Compare(vI, vJ); c != 0 { return c > 0 } } else if nI != nJ { return nI > nJ } // Tie-break on specificity (most complete wins) if sI, sJ := semverSpecificity(pkgs[i].Version), semverSpecificity(pkgs[j].Version); sI != sJ { return sI > sJ } // Tie-break on creation time (newest wins) return pkgs[i].CreatedAt > pkgs[j].CreatedAt }) // First item after descending sort is the highest version latestPackages = append(latestPackages, pkgs[0]) } ociAuth := remote.WithAuth(&authn.Basic{ Username: os.Getenv("GITEA_USER"), Password: os.Getenv("GITEA_RW_PACKAGE_TOKEN"), }) ociOrg := strings.ToLower(os.Getenv("GITEA_ORG")) var registryItems []types.RegistryContainer // 4. Fetch OCI metadata only for the highest version of each package for _, pkg := range latestPackages { pkgNameLower := strings.ToLower(pkg.Name) imageRef := fmt.Sprintf("git.dcntrlized.org/%s/%s:%s", ociOrg, pkgNameLower, pkg.Version) // The Gitea package API does not link org-level container packages to a // repository, so the repository URL is resolved from the image's OCI // labels below, or derived from the image reference as a last resort. item := types.RegistryContainer{ Name: pkg.Name, Version: normalizeSemver(pkg.Version), Image: imageRef, CreatedAt: pkg.CreatedAt, } ref, err := name.ParseReference(imageRef) if err != nil { logger.Error("Failed to parse image reference %s: %v", imageRef, err) registryItems = append(registryItems, item) continue } img, err := remote.Image(ref, ociAuth, remote.WithContext(r.Context())) if err != nil { logger.Error("Failed to fetch OCI metadata for %s: %v", imageRef, err) registryItems = append(registryItems, item) continue } // Fetch image digest (sha256:...) if digest, err := img.Digest(); err == nil { item.Digest = digest.String() } // 1. Extract standard OCI annotations from OCI Manifest if manifest, err := img.Manifest(); err == nil && manifest != nil { if title, ok := manifest.Annotations["org.opencontainers.image.title"]; ok { item.Title = title } if vendor, ok := manifest.Annotations["org.opencontainers.image.vendor"]; ok { item.Vendor = vendor } if author, ok := manifest.Annotations["org.opencontainers.image.authors"]; ok { item.Author = author } if desc, ok := manifest.Annotations["org.opencontainers.image.description"]; ok { item.Description = desc } if src, ok := manifest.Annotations["org.opencontainers.image.source"]; ok && src != "" { item.Source = src } if rev, ok := manifest.Annotations["org.opencontainers.image.revision"]; ok { item.Revision = rev } if lic, ok := manifest.Annotations["org.opencontainers.image.licenses"]; ok { item.Licenses = lic } if url, ok := manifest.Annotations["org.opencontainers.image.url"]; ok { item.URL = url } if doc, ok := manifest.Annotations["org.opencontainers.image.documentation"]; ok { item.Documentation = doc } if created, ok := manifest.Annotations["org.opencontainers.image.created"]; ok && created != "" { item.CreatedAt = created } } // 2. Fallback to Image Config Labels (where docker buildx puts them when targeting v2 registries) if configFile, err := img.ConfigFile(); err == nil && configFile != nil { labels := configFile.Config.Labels if item.Title == "" { item.Title = labels["org.opencontainers.image.title"] } if item.Vendor == "" { item.Vendor = labels["org.opencontainers.image.vendor"] } if item.Author == "" { item.Author = labels["org.opencontainers.image.authors"] } if item.Description == "" { item.Description = labels["org.opencontainers.image.description"] } if src, ok := labels["org.opencontainers.image.source"]; ok && src != "" { item.Source = src } if item.Revision == "" { item.Revision = labels["org.opencontainers.image.revision"] } if item.Licenses == "" { item.Licenses = labels["org.opencontainers.image.licenses"] } if item.URL == "" { item.URL = labels["org.opencontainers.image.url"] } if item.Documentation == "" { item.Documentation = labels["org.opencontainers.image.documentation"] } if item.CreatedAt == "" && labels["org.opencontainers.image.created"] != "" { item.CreatedAt = labels["org.opencontainers.image.created"] } } // Resolve the source repository URL: OCI source label if present, else // derived from the image reference (host/org/repo). if item.Source == "" { item.Source = repoURLFromImageRef(imageRef) } // The compose file is fetched from the git repository, so fall back to // the resolved source URL when the OCI url label is absent. if item.URL == "" { item.URL = item.Source } registryItems = append(registryItems, item) } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(registryItems); err != nil { logger.Error("Failed to write response: %v", err) } } // repoURLFromImageRef derives the repository browse URL from a container image // reference of the form host/org/repo:tag (https://host/org/repo). func repoURLFromImageRef(imageRef string) string { ref, err := name.ParseReference(imageRef) if err != nil { return "" } return "https://" + ref.Context().RegistryStr() + "/" + ref.Context().RepositoryStr() }