Files
mcr/internal/webserver/server.go
Kyle Isom d5580f01f2 Migrate module path from kyle/ to mc/ org
All import paths updated to git.wntrmute.dev/mc/. Bumps mcdsl to v1.2.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:05:59 -07:00

140 lines
3.8 KiB
Go

// 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
}