summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorColin Wilk <colin@wilk.cx>2026-10-30 10:04:02 +0100
committerColin Wilk <colin@wilk.cx>2026-10-30 10:07:43 +0100
commitb03b00869391cd70ef1e40561f72dffbd2f47b29 (patch)
treefd1941854bea58ec464b81ac8b0d8627cbed2e74
downloadcr-web-b03b00869391cd70ef1e40561f72dffbd2f47b29.tar.gz
cr-web-b03b00869391cd70ef1e40561f72dffbd2f47b29.zip
Initial Commit
-rw-r--r--Dockerfile24
-rw-r--r--assets/styles.css207
-rw-r--r--go.mod3
-rw-r--r--main.go49
-rw-r--r--registry/models.go75
-rw-r--r--registry/provider.go268
-rw-r--r--templates/error.gohtml5
-rw-r--r--templates/layout.gohtml17
-rw-r--r--templates/manifest.gohtml52
-rw-r--r--templates/repositories.gohtml17
-rw-r--r--templates/repository.gohtml22
-rw-r--r--web/server.go181
-rw-r--r--web/utils.go103
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;
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..bbd1050
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module git.wilk.cx/cr-browser
+
+go 1.26
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..2f27aee
--- /dev/null
+++ b/main.go
@@ -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 := &registry.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"
+}