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 /web | |
| download | cr-web-b03b00869391cd70ef1e40561f72dffbd2f47b29.tar.gz cr-web-b03b00869391cd70ef1e40561f72dffbd2f47b29.zip | |
Initial Commit
Diffstat (limited to 'web')
| -rw-r--r-- | web/server.go | 181 | ||||
| -rw-r--r-- | web/utils.go | 103 |
2 files changed, 284 insertions, 0 deletions
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" +} |