diff options
| author | Colin Wilk <colin@wilk.cx> | 2026-10-30 10:04:02 +0100 |
|---|---|---|
| committer | Colin Wilk <colin@wilk.cx> | 2026-10-30 10:07:43 +0100 |
| commit | b03b00869391cd70ef1e40561f72dffbd2f47b29 (patch) | |
| tree | fd1941854bea58ec464b81ac8b0d8627cbed2e74 | |
| download | cr-web-b03b00869391cd70ef1e40561f72dffbd2f47b29.tar.gz cr-web-b03b00869391cd70ef1e40561f72dffbd2f47b29.zip | |
Initial Commit
| -rw-r--r-- | Dockerfile | 24 | ||||
| -rw-r--r-- | assets/styles.css | 207 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | main.go | 49 | ||||
| -rw-r--r-- | registry/models.go | 75 | ||||
| -rw-r--r-- | registry/provider.go | 268 | ||||
| -rw-r--r-- | templates/error.gohtml | 5 | ||||
| -rw-r--r-- | templates/layout.gohtml | 17 | ||||
| -rw-r--r-- | templates/manifest.gohtml | 52 | ||||
| -rw-r--r-- | templates/repositories.gohtml | 17 | ||||
| -rw-r--r-- | templates/repository.gohtml | 22 | ||||
| -rw-r--r-- | web/server.go | 181 | ||||
| -rw-r--r-- | web/utils.go | 103 |
13 files changed, 1023 insertions, 0 deletions
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3405c1a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# --- Builder --- +FROM golang:alpine AS builder +WORKDIR /app +COPY go.mod main.go ./ +COPY registry ./registry +COPY web ./web +RUN CGO_ENABLED=0 go build -o registry-browser . + +# --- Final Container --- +FROM alpine:3.21 + +RUN addgroup -S registry && adduser -S registry -G registry + +WORKDIR /app + +COPY --from=builder --chown=registry:registry /app/registry-browser /usr/local/bin/registry-browser +COPY templates ./templates +COPY assets ./assets + +USER registry + +EXPOSE 8080 + +CMD ["registry-browser", "--http=0.0.0.0:8080"] diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..f87db3a --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,207 @@ +/* Fira Code and the dark palette from the supplied cgit theme. */ +@font-face { + font-family: "Fira Code"; + src: + url("https://s3.d-04.de/static/_fonts/Fira_Code_v6.2/woff2/FiraCode-VF.woff2") + format("woff2-variations"), + url("https://s3.d-04.de/static/_fonts/Fira_Code_v6.2/woff2/FiraCode-VF.woff2") + format("woff2"); + font-weight: 300 700; + font-style: normal; + font-display: swap; +} + +:root { + color-scheme: dark; + --background: #212529; + --stripe: #343a40; + --inset: #131618; + --foreground: #f8f9fa; + --secondary: #ced4da; + --muted: #78828a; + --border: #495057; + --link: #3af; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + color: var(--foreground); + background: var(--background); + font-family: "Fira Code", monospace; + font-variant-ligatures: contextual; +} + +body { + padding: 4px; + font-size: 10pt; + line-height: 1.4; +} + +main { + padding: 2em; + border-bottom: 3px solid #bbb; + overflow-x: auto; +} + +h1, +h2 { + color: var(--foreground); + line-height: 1.3; +} + +h1 { + margin: 0 0 .75em; + font-size: 180%; +} + +h2 { + margin: 0 0 .5em; + font-size: 125%; +} + +p { + margin: 0 0 1em; +} + +a { + color: var(--link); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Compact, full-width listings with the cgit alternating row colors. */ +table { + width: 100%; + border: none; + border-collapse: collapse; +} + +th, +td { + border: none; + text-align: left; + vertical-align: baseline; +} + +th { + padding: .1em .5em .05em; + font-weight: bold; +} + +td { + padding: .1em .5em; +} + +thead, +tfoot { + background: var(--inset); +} + +tbody tr:nth-child(odd) { + background: var(--stripe); +} + +tbody tr:nth-child(even) { + background: var(--background); +} + +td a { + color: var(--foreground); +} + +td a:hover, +td a:focus-visible { + color: var(--link); +} + +td[title] { + white-space: nowrap; +} + +code, +pre, +.monospace { + font-family: "Fira Code", monospace; + font-feature-settings: "calt" 1; +} + +.literal { + max-width: 100%; + margin: 0; + padding: .5em 1em; + color: var(--foreground); + background: var(--inset); + border: 1px solid var(--border); + border-radius: 0; + font-size: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 4; +} + +/* Keep any form controls consistent with the supplied square-edged theme. */ +input, +select, +textarea, +button { + margin: 0; + padding: 3px 8px; + color: var(--secondary); + background: var(--inset); + border: 1px solid var(--border); + border-radius: 0; + font-family: inherit; + font-variant-ligatures: contextual; + font-size: 9pt; +} + +input, +select, +button { + min-height: 26px; +} + +button, +input[type="submit"] { + padding: 3px 12px; + background: var(--stripe); + cursor: pointer; +} + +button:hover, +input[type="submit"]:hover { + background: var(--border); + border-color: var(--muted); +} + +input:focus, +select:focus, +textarea:focus { + background: #000; + border-color: var(--link); +} + +:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; +} + +::selection { + color: var(--foreground); + background: #0d3d64; +} + +/* Wide digest tables scroll instead of squeezing their columns on phones. */ +@media (max-width: 640px) { + main { + padding: 1em; + } +} @@ -0,0 +1,3 @@ +module git.wilk.cx/cr-browser + +go 1.26 @@ -0,0 +1,49 @@ +package main + +import ( + "flag" + "log" + "net/http" + "os" + "strings" + "time" + + "git.wilk.cx/cr-browser/registry" + "git.wilk.cx/cr-browser/web" +) + +func main() { + httpAddr := flag.String("http", ":8080", "HTTP listen address") + flag.Parse() + + registryURL := os.Getenv("REGISTRY_URL") + if registryURL == "" { + registryURL = "http://localhost:5000" + } + extRegistryURL := os.Getenv("EXT_REGISTRY_URL") + if extRegistryURL == "" { + extRegistryURL = registryURL + } + registryHost := strings.TrimRight(strings.TrimPrefix(strings.TrimPrefix(extRegistryURL, "https://"), "http://"), "/") + + provider := ®istry.HTTPProvider{ + BaseURL: registryURL, + Client: &http.Client{ + Timeout: 10 * time.Second, + }, + } + + server, err := web.NewServer(provider, registryHost) + if err != nil { + log.Fatal(err) + } + + httpServer := &http.Server{ + Addr: *httpAddr, + Handler: server.Handler(), + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("Listening on %s", *httpAddr) + log.Fatal(httpServer.ListenAndServe()) +} diff --git a/registry/models.go b/registry/models.go new file mode 100644 index 0000000..11e1db8 --- /dev/null +++ b/registry/models.go @@ -0,0 +1,75 @@ +// Package registry is responsible for fetching data from the backend registry +// and providing the data for the UI components. +package registry + +import ( + "context" + "errors" + "time" +) + +// ErrNotFound should be returned for missing repositories/manifests. +var ErrNotFound = errors.New("registry resource not found") + +type Provider interface { + Repositories(ctx context.Context) ([]Repository, error) + Repository(ctx context.Context, name string) (Repository, error) + Manifest(ctx context.Context, repository, digest string) (Manifest, error) +} + +type Repository struct { + Name string `json:"name"` + UpdatedAt time.Time `json:"updated_at"` + Age string `json:"age,omitempty"` + Tags []Tag `json:"tags"` +} + +type Tag struct { + Name string `json:"name"` + Aliases []string `json:"aliases,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + Age string `json:"age,omitempty"` + Platforms string `json:"platforms,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + ManifestDigest string `json:"manifest_digest"` +} + +type Manifest struct { + Repository string `json:"repository"` + Digest string `json:"digest"` + Kind string `json:"kind"` // "index" for a list; "image" for layers. + SizeBytes int64 `json:"size_bytes,omitempty"` // Registry-reported image total, when available. + Tags []TagReference `json:"tags"` + Platforms []PlatformManifest `json:"platforms,omitempty"` + Layers []Layer `json:"layers,omitempty"` +} + +func (m Manifest) TotalSizeBytes() int64 { + if m.SizeBytes > 0 { + return m.SizeBytes + } + var total int64 + for _, layer := range m.Layers { + total += layer.SizeBytes + } + return total +} + +type TagReference struct { + Name string `json:"name"` + UpdatedAt time.Time `json:"updated_at"` + Age string `json:"age,omitempty"` + ManifestDigest string `json:"manifest_digest"` +} + +type PlatformManifest struct { + Platform string `json:"platform"` + Variant string `json:"variant,omitempty"` + SizeBytes int64 `json:"size_bytes"` + Digest string `json:"digest"` +} + +type Layer struct { + Digest string `json:"digest"` + SizeBytes int64 `json:"size_bytes"` +} diff --git a/registry/provider.go b/registry/provider.go new file mode 100644 index 0000000..0c90bec --- /dev/null +++ b/registry/provider.go @@ -0,0 +1,268 @@ +package registry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type HTTPProvider struct { + BaseURL string + Client *http.Client +} + +// Compile time verify that HTTPProvider implements Provider interface +var _ Provider = (*HTTPProvider)(nil) + +func (p *HTTPProvider) Repositories(ctx context.Context) ([]Repository, error) { + url := strings.TrimRight(p.BaseURL, "/") + "/v2/_zot/ext/search" + body := `{"query":"{ RepoListWithNewestImage { Results { Name LastUpdated } } }"}` + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, ErrNotFound + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry returned %s", resp.Status) + } + + var result struct { + Data struct { + RepoListWithNewestImage struct { + Results []struct { + Name string + LastUpdated time.Time + } + } + } + Errors []struct { + Message string + } + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + if len(result.Errors) > 0 { + return nil, fmt.Errorf("registry search: %s", result.Errors[0].Message) + } + + repositories := make([]Repository, 0, len(result.Data.RepoListWithNewestImage.Results)) + for _, repo := range result.Data.RepoListWithNewestImage.Results { + repositories = append(repositories, Repository{ + Name: repo.Name, + UpdatedAt: repo.LastUpdated, + Tags: []Tag{}, + }) + } + + return repositories, nil +} + +func (p *HTTPProvider) Repository(ctx context.Context, name string) (Repository, error) { + body, err := json.Marshal(map[string]string{ + "query": fmt.Sprintf("{ ImageList(repo: %q) { Results { Tag Digest LastUpdated Size } } }", name), + }) + if err != nil { + return Repository{}, err + } + + url := strings.TrimRight(p.BaseURL, "/") + "/v2/_zot/ext/search" + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body))) + if err != nil { + return Repository{}, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.Client.Do(req) + if err != nil { + return Repository{}, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return Repository{}, ErrNotFound + } + + if resp.StatusCode != http.StatusOK { + return Repository{}, fmt.Errorf("registry returned %s", resp.Status) + } + + var result struct { + Data struct { + ImageList struct { + Results []struct { + Tag string + Digest string + LastUpdated time.Time + Size int64 `json:"Size,string"` + } + } + } + Errors []struct { + Message string + } + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return Repository{}, err + } + if len(result.Errors) > 0 { + return Repository{}, fmt.Errorf("registry search: %s", result.Errors[0].Message) + } + + repo := Repository{ + Name: name, + Tags: make([]Tag, 0, len(result.Data.ImageList.Results)), + } + + for _, image := range result.Data.ImageList.Results { + repo.Tags = append(repo.Tags, Tag{ + Name: image.Tag, + ManifestDigest: image.Digest, + UpdatedAt: image.LastUpdated, + SizeBytes: image.Size, + }) + if image.LastUpdated.After(repo.UpdatedAt) { + repo.UpdatedAt = image.LastUpdated + } + } + + return repo, nil +} + +func (p *HTTPProvider) Manifest(ctx context.Context, repository, digest string) (Manifest, error) { + body, err := json.Marshal(map[string]string{ + "query": fmt.Sprintf(`{ ImageList(repo: %q) { Results { + Tag Digest MediaType Size LastUpdated + Manifests { Digest Size Platform { Os Arch } Layers { Digest Size } } + } } }`, repository), + }) + if err != nil { + return Manifest{}, err + } + + url := strings.TrimRight(p.BaseURL, "/") + "/v2/_zot/ext/search" + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body))) + if err != nil { + return Manifest{}, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.Client.Do(req) + if err != nil { + return Manifest{}, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return Manifest{}, ErrNotFound + } + if resp.StatusCode != http.StatusOK { + return Manifest{}, fmt.Errorf("registry returned %s", resp.Status) + } + + type manifestSummary struct { + Digest string + Size int64 `json:"Size,string"` + Platform struct { + Os string + Arch string + } + Layers []struct { + Digest string + Size int64 `json:"Size,string"` + } + } + var result struct { + Data struct { + ImageList struct { + Results []struct { + Tag string + Digest string + MediaType string + Size int64 `json:"Size,string"` + LastUpdated time.Time + Manifests []manifestSummary + } + } + } + Errors []struct { + Message string + } + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return Manifest{}, err + } + if len(result.Errors) > 0 { + return Manifest{}, fmt.Errorf("registry search: %s", result.Errors[0].Message) + } + + manifest := Manifest{Repository: repository, Digest: digest} + for _, image := range result.Data.ImageList.Results { + var summary *manifestSummary + for i := range image.Manifests { + if image.Manifests[i].Digest == digest { + summary = &image.Manifests[i] + break + } + } + if image.Digest != digest && summary == nil { + continue + } + + // A platform manifest also lists tags referencing its parent index. + manifest.Tags = append(manifest.Tags, TagReference{ + Name: image.Tag, + UpdatedAt: image.LastUpdated, + ManifestDigest: image.Digest, + }) + if manifest.Kind != "" { + continue + } + + manifest.Kind = "image" + if image.Digest == digest { + manifest.SizeBytes = image.Size + if image.MediaType == "application/vnd.oci.image.index.v1+json" || + image.MediaType == "application/vnd.docker.distribution.manifest.list.v2+json" { + manifest.Kind = "index" + for _, platform := range image.Manifests { + manifest.Platforms = append(manifest.Platforms, PlatformManifest{ + Platform: strings.Trim(platform.Platform.Os+"/"+platform.Platform.Arch, "/"), + SizeBytes: platform.Size, + Digest: platform.Digest, + }) + } + } + } + if manifest.Kind == "image" && summary != nil { + manifest.SizeBytes = summary.Size + for _, layer := range summary.Layers { + manifest.Layers = append(manifest.Layers, Layer{ + Digest: layer.Digest, + SizeBytes: layer.Size, + }) + } + } + } + if manifest.Kind == "" { + return Manifest{}, ErrNotFound + } + + return manifest, nil +} diff --git a/templates/error.gohtml b/templates/error.gohtml new file mode 100644 index 0000000..9b7ba03 --- /dev/null +++ b/templates/error.gohtml @@ -0,0 +1,5 @@ +{{define "error"}}{{template "header" .}} + <h1>{{.Status}}</h1> + <p>{{.Message}}</p> + <p><a href="/">Back to registry</a></p> +{{template "footer" .}}{{end}} diff --git a/templates/layout.gohtml b/templates/layout.gohtml new file mode 100644 index 0000000..cf6d839 --- /dev/null +++ b/templates/layout.gohtml @@ -0,0 +1,17 @@ +{{define "header"}}<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{.Title}}</title> + <link rel="stylesheet" href="/assets/styles.css"> +</head> +<body> +<main> +{{end}} + +{{define "footer"}} +</main> +</body> +</html> +{{end}} diff --git a/templates/manifest.gohtml b/templates/manifest.gohtml new file mode 100644 index 0000000..93ebecc --- /dev/null +++ b/templates/manifest.gohtml @@ -0,0 +1,52 @@ +{{define "manifest"}}{{template "header" .}} + <h1>Manifest in repository <a href="{{repoURL .Repository.Name}}">{{.Repository.Name}}</a> in <a href="/">registry</a></h1> + <h2>Tags referencing manifest</h2> + <table> + <thead><tr><th scope="col">Tag</th><th scope="col">Last updated</th></tr></thead> + <tbody> + {{range .Manifest.Tags}} + <tr> + <td>{{if eq .ManifestDigest $.Manifest.Digest}}{{.Name}}{{else}}<a href="{{manifestURL $.Repository.Name .ManifestDigest .Name}}">{{.Name}}</a>{{end}}</td> + <td title="{{timestamp .UpdatedAt}}">{{age .UpdatedAt .Age}}</td> + </tr> + {{else}} + <tr><td colspan="2">No tags reference this manifest.</td></tr> + {{end}} + </tbody> + </table> + <br> + <h2>Pull address</h2> + <div><pre class="literal" style="display: inline-block">{{pull .RegistryHost .Repository.Name .SelectedTag .Manifest.Digest}}</pre></div> + <br> + + {{if eq .Manifest.Kind "index"}} + <h2>Manifests in list</h2> + <table> + <thead><tr><th scope="col" colspan="2">OS/Arch</th><th scope="col">Size</th><th scope="col">Pull address</th></tr></thead> + <tbody> + {{range .Manifest.Platforms}} + <tr> + <td>{{.Platform}}</td><td>{{.Variant}}</td> + <td style="text-align: right" title="{{.SizeBytes}} bytes">{{size .SizeBytes}}</td> + <td>{{pull $.RegistryHost $.Repository.Name $.SelectedTag ""}}@<a class="monospace" href="{{manifestURL $.Repository.Name .Digest $.SelectedTag}}">{{.Digest}}</a></td> + </tr> + {{else}} + <tr><td colspan="4">No platform manifests found.</td></tr> + {{end}} + </tbody> + </table> + {{else}} + <h2>Layers in manifest</h2> + <table> + <thead><tr><th scope="col">Digest</th><th scope="col">Size</th></tr></thead> + <tbody> + {{range .Manifest.Layers}} + <tr><td class="monospace">{{.Digest}}</td><td style="text-align: right" title="{{.SizeBytes}} bytes">{{size .SizeBytes}}</td></tr> + {{else}} + <tr><td colspan="2">No layers found.</td></tr> + {{end}} + </tbody> + <tfoot><tr><td><b>Total size</b></td><td style="text-align: right" title="{{.Manifest.TotalSizeBytes}} bytes"><b>{{size .Manifest.TotalSizeBytes}}</b></td></tr></tfoot> + </table> + {{end}} +{{template "footer" .}}{{end}} diff --git a/templates/repositories.gohtml b/templates/repositories.gohtml new file mode 100644 index 0000000..09b5714 --- /dev/null +++ b/templates/repositories.gohtml @@ -0,0 +1,17 @@ +{{define "repositories"}}{{template "header" .}} + <p>This is a container registry.</p> + <h1>Repositories</h1> + <table> + <thead><tr><th scope="col">Name</th><th scope="col">Last updated</th></tr></thead> + <tbody> + {{range .Repositories}} + <tr> + <td><a href="{{repoURL .Name}}">{{.Name}}</a></td> + <td title="{{timestamp .UpdatedAt}}">{{age .UpdatedAt .Age}}</td> + </tr> + {{else}} + <tr><td colspan="2">No repositories found.</td></tr> + {{end}} + </tbody> + </table> +{{template "footer" .}}{{end}} diff --git a/templates/repository.gohtml b/templates/repository.gohtml new file mode 100644 index 0000000..5996684 --- /dev/null +++ b/templates/repository.gohtml @@ -0,0 +1,22 @@ +{{define "repository"}}{{template "header" .}} + <h1>Tags for {{.Repository.Name}} in <a href="/">registry</a></h1> + <table> + <thead><tr> + <th scope="col">Tag</th><th scope="col">Aliases</th><th scope="col">Last updated</th> + <th scope="col">Size or platforms</th><th scope="col">Pull address</th> + </tr></thead> + <tbody> + {{range .Repository.Tags}} + <tr> + <td><a href="{{manifestURL $.Repository.Name .ManifestDigest .Name}}">{{.Name}}</a></td> + <td>{{join .Aliases " "}}</td> + <td title="{{timestamp .UpdatedAt}}">{{age .UpdatedAt .Age}}</td> + <td>{{if .Platforms}}{{.Platforms}}{{else}}{{size .SizeBytes}}{{end}}</td> + <td>{{pull $.RegistryHost $.Repository.Name .Name ""}}</td> + </tr> + {{else}} + <tr><td colspan="5">No tags found.</td></tr> + {{end}} + </tbody> + </table> +{{template "footer" .}}{{end}} diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..121b197 --- /dev/null +++ b/web/server.go @@ -0,0 +1,181 @@ +package web + +import ( + "bytes" + "context" + "errors" + "html/template" + "log" + "net/http" + "strings" + + "git.wilk.cx/cr-browser/registry" +) + +type RepositoryLister interface { + Repositories(ctx context.Context) ([]registry.Repository, error) + Repository(ctx context.Context, name string) (registry.Repository, error) + Manifest(ctx context.Context, repository, digest string) (registry.Manifest, error) +} + +type Server struct { + provider RepositoryLister + registryHost string + tmpl *template.Template +} + +type PageData struct { + Title string + RegistryHost string + Repositories []registry.Repository + Repository registry.Repository + Manifest registry.Manifest + SelectedTag string +} + +func NewServer(provider RepositoryLister, registryHost string) (*Server, error) { + tmpl, err := template.New(""). + Funcs(templateFuncs()). + ParseGlob("templates/*.gohtml") + if err != nil { + return nil, err + } + + return &Server{ + provider: provider, + registryHost: registryHost, + tmpl: tmpl, + }, nil +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Static assets + mux.Handle( + "/assets/", + http.StripPrefix( + "/assets/", + http.FileServer(http.Dir("./assets")), + ), + ) + + // Repository listing page + mux.HandleFunc("/", s.repositories) + mux.HandleFunc("/r/{name...}", s.repository) + + return mux +} + +func (s *Server) repositories(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + repos, err := s.provider.Repositories(r.Context()) + if err != nil { + log.Printf("fetch repositories: %v", err) + http.Error(w, "failed to fetch repositories", http.StatusBadGateway) + return + } + + data := PageData{ + Title: "Container Registry", + RegistryHost: s.registryHost, + Repositories: repos, + } + + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "repositories", data); err != nil { + log.Printf("render repositories: %v", err) + http.Error(w, "failed to render page", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(buf.Bytes()) +} + +func (s *Server) repository(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + name := strings.TrimSuffix(r.PathValue("name"), "/") + // Dispatch manifest links while allowing slashes in repository names. + if i := strings.LastIndex(name, "/m/"); i >= 0 { + digest := name[i+3:] + if strings.Contains(digest, ":") && !strings.Contains(digest, "/") { + s.manifest(w, r, name[:i], digest) + return + } + } + + repo, err := s.provider.Repository(r.Context(), name) + if err != nil { + if errors.Is(err, registry.ErrNotFound) { + http.NotFound(w, r) + return + } + + log.Printf("fetch repository: %v", err) + http.Error(w, "failed to fetch repository", http.StatusBadGateway) + return + } + + data := PageData{ + Title: repo.Name, + RegistryHost: s.registryHost, + Repository: repo, + } + + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "repository", data); err != nil { + log.Printf("render repository: %v", err) + http.Error(w, "failed to render page", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(buf.Bytes()) +} + +func (s *Server) manifest(w http.ResponseWriter, r *http.Request, repository, digest string) { + manifest, err := s.provider.Manifest(r.Context(), repository, digest) + if err != nil { + if errors.Is(err, registry.ErrNotFound) { + http.NotFound(w, r) + return + } + log.Printf("fetch manifest: %v", err) + http.Error(w, "failed to fetch manifest", http.StatusBadGateway) + return + } + + data := PageData{ + Title: "Manifest in " + repository, + RegistryHost: s.registryHost, + Repository: registry.Repository{Name: repository}, + Manifest: manifest, + SelectedTag: r.URL.Query().Get("tag"), + } + + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "manifest", data); err != nil { + log.Printf("render manifest: %v", err) + http.Error(w, "failed to render page", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(buf.Bytes()) +} diff --git a/web/utils.go b/web/utils.go new file mode 100644 index 0000000..18b5b52 --- /dev/null +++ b/web/utils.go @@ -0,0 +1,103 @@ +package web + +import ( + "fmt" + "html/template" + "net/url" + "strings" + "time" +) + +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "repoURL": repoURL, + "manifestURL": manifestURL, + "pull": pullAddress, + "size": formatSize, + "timestamp": timestamp, + "age": displayAge, + "join": strings.Join, + } +} + +// sanitizeURLPath escapes each path segment separately, preserving slashes in +// names such as group/repository. Appends a trailing slash. +func sanitizeURLPath(path string) string { + parts := strings.Split(path, "/") + for i := range parts { + parts[i] = url.PathEscape(parts[i]) + } + return strings.Join(parts, "/") + "/" +} + +func repoURL(name string) string { + return "/r/" + sanitizeURLPath(name) +} + +func manifestURL(repository, digest, tag string) string { + path := repoURL(repository) + "m/" + sanitizeURLPath(digest) + if tag != "" { + path += "?" + url.Values{"tag": {tag}}.Encode() + } + return path +} + +func pullAddress(host, repository, tag, digest string) string { + address := strings.TrimSuffix(host, "/") + "/" + repository + if tag != "" { + address += ":" + tag + } + if digest != "" { + address += "@" + digest + } + return address +} + +func formatSize(value int64) string { + if value >= 1024*1024 { + return fmt.Sprintf("%.1fMB", float64(value)/(1024*1024)) + } + if value >= 1024 { + return fmt.Sprintf("%.1fKB", float64(value)/1024) + } + return fmt.Sprintf("%dB", value) +} + +func timestamp(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.DateTime + " MST") +} + +func displayAge(updated time.Time, frozen string) string { + if frozen != "" { + return frozen + } + if updated.IsZero() { + return "unknown" + } + elapsed := time.Since(updated) + if elapsed < time.Minute { + return "just now" + } + for _, unit := range []struct { + duration time.Duration + label string + }{ + {365 * 24 * time.Hour, "year"}, + {30 * 24 * time.Hour, "month"}, + {24 * time.Hour, "day"}, + {time.Hour, "hour"}, + {time.Minute, "minute"}, + } { + if count := int(elapsed / unit.duration); count > 0 { + suffix := "s" + if count == 1 { + suffix = "" + } + return fmt.Sprintf("%d %s%s ago", count, unit.label, suffix) + } + } + return "just now" +} |