// Package webserver implements the MCR web UI server. // // It serves HTML pages rendered from Go templates with htmx for // interactive elements. All data is fetched via gRPC from the main // mcrsrv API server. Authentication is handled via MCIAS, with session // tokens stored in secure HttpOnly cookies. package webserver import ( "io/fs" "log" "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1" "git.wntrmute.dev/mc/mcr/web" ) // LoginFunc authenticates a user and returns a bearer token. type LoginFunc func(username, password string) (token string, expiresIn int, err error) // ValidateFunc validates a bearer token and returns the user's roles. type ValidateFunc func(token string) (roles []string, err error) // Server is the MCR web UI server. type Server struct { router chi.Router templates *templateSet registry mcrv1.RegistryServiceClient policy mcrv1.PolicyServiceClient audit mcrv1.AuditServiceClient admin mcrv1.AdminServiceClient loginFn LoginFunc validateFn ValidateFunc csrfKey []byte // 32-byte key for HMAC signing } // New creates a new web UI server with the given gRPC clients and login function. func New( registry mcrv1.RegistryServiceClient, policy mcrv1.PolicyServiceClient, audit mcrv1.AuditServiceClient, admin mcrv1.AdminServiceClient, loginFn LoginFunc, validateFn ValidateFunc, csrfKey []byte, ) (*Server, error) { tmpl, err := loadTemplates() if err != nil { return nil, err } s := &Server{ templates: tmpl, registry: registry, policy: policy, audit: audit, admin: admin, loginFn: loginFn, validateFn: validateFn, csrfKey: csrfKey, } s.router = s.buildRouter() return s, nil } // Handler returns the http.Handler for the server. func (s *Server) Handler() http.Handler { return s.router } // buildRouter sets up the chi router with all routes and middleware. func (s *Server) buildRouter() chi.Router { r := chi.NewRouter() // Global middleware. r.Use(middleware.Recoverer) r.Use(middleware.RequestID) r.Use(middleware.RealIP) // Static files (no auth required). staticFS, err := fs.Sub(web.Content, "static") if err != nil { log.Fatalf("webserver: failed to create static sub-filesystem: %v", err) } r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // Public routes (no session required). r.Get("/login", s.handleLoginPage) r.Post("/login", s.handleLoginSubmit) r.Get("/logout", s.handleLogout) // Protected routes (session required). r.Group(func(r chi.Router) { r.Use(s.sessionMiddleware) r.Get("/", s.handleDashboard) // Repository routes — name may contain slashes. r.Get("/repositories", s.handleRepositories) r.Get("/repositories/*", s.handleRepositoryOrManifest) // Policy routes (admin — gRPC interceptors enforce this). r.Get("/policies", s.handlePolicies) r.Post("/policies", s.handleCreatePolicy) r.Post("/policies/{id}/toggle", s.handleTogglePolicy) r.Post("/policies/{id}/delete", s.handleDeletePolicy) // Audit routes (admin — gRPC interceptors enforce this). r.Get("/audit", s.handleAudit) }) return r } // handleRepositoryOrManifest dispatches between repository detail and // manifest detail based on the URL path. This is necessary because // repository names can contain slashes. func (s *Server) handleRepositoryOrManifest(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if idx := lastIndex(path, "/manifests/"); idx >= 0 { s.handleManifestDetail(w, r) return } s.handleRepositoryDetail(w, r) } // lastIndex returns the index of the last occurrence of sep in s, or -1. func lastIndex(s, sep string) int { for i := len(s) - len(sep); i >= 0; i-- { if s[i:i+len(sep)] == sep { return i } } return -1 }