summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/server.go181
-rw-r--r--web/utils.go103
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"
+}