Phases 11, 12: mcrctl CLI tool and mcr-web UI
Phase 11 implements the admin CLI with dual REST/gRPC transport, global flags (--server, --grpc, --token, --ca-cert, --json), and all commands: status, repo list/delete, policy CRUD, audit tail, gc trigger/status/reconcile, and snapshot. Phase 12 implements the HTMX web UI with chi router, session-based auth (HttpOnly/Secure/SameSite=Strict cookies), CSRF protection (HMAC-SHA256 signed double-submit), and pages for dashboard, repositories, manifest detail, policy management, and audit log. Security: CSRF via signed double-submit cookie, session cookies with HttpOnly/Secure/SameSite=Strict, TLS 1.3 minimum on all connections, form body size limits via http.MaxBytesReader. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
457
internal/webserver/handlers.go
Normal file
457
internal/webserver/handlers.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
mcrv1 "git.wntrmute.dev/kyle/mcr/gen/mcr/v1"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// grpcContext creates a context with the bearer token from the session
|
||||
// attached as gRPC outgoing metadata.
|
||||
func grpcContext(r *http.Request) context.Context {
|
||||
token := tokenFromContext(r.Context())
|
||||
return metadata.AppendToOutgoingContext(r.Context(), "authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
// handleDashboard renders the dashboard with repo stats and recent activity.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := grpcContext(r)
|
||||
|
||||
repos, err := s.registry.ListRepositories(ctx, &mcrv1.ListRepositoriesRequest{})
|
||||
if err != nil {
|
||||
s.renderError(w, "dashboard", "Failed to load repositories.", err)
|
||||
return
|
||||
}
|
||||
|
||||
var repoCount int
|
||||
var totalSize int64
|
||||
for _, repo := range repos.GetRepositories() {
|
||||
repoCount++
|
||||
totalSize += repo.GetTotalSize()
|
||||
}
|
||||
|
||||
// Fetch recent audit events for dashboard activity.
|
||||
var events []*mcrv1.AuditEvent
|
||||
auditResp, auditErr := s.audit.ListAuditEvents(ctx, &mcrv1.ListAuditEventsRequest{
|
||||
Pagination: &mcrv1.PaginationRequest{Limit: 10},
|
||||
})
|
||||
if auditErr == nil {
|
||||
events = auditResp.GetEvents()
|
||||
}
|
||||
// If audit fails with PermissionDenied, just show no events (user is not admin).
|
||||
|
||||
s.templates.render(w, "dashboard", map[string]any{
|
||||
"Session": true,
|
||||
"RepoCount": repoCount,
|
||||
"TotalSize": formatSize(totalSize),
|
||||
"Events": events,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRepositories renders the repository list.
|
||||
func (s *Server) handleRepositories(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := grpcContext(r)
|
||||
|
||||
resp, err := s.registry.ListRepositories(ctx, &mcrv1.ListRepositoriesRequest{})
|
||||
if err != nil {
|
||||
s.renderError(w, "repositories", "Failed to load repositories.", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.templates.render(w, "repositories", map[string]any{
|
||||
"Session": true,
|
||||
"Repositories": resp.GetRepositories(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleRepositoryDetail renders a single repository's tags and manifests.
|
||||
func (s *Server) handleRepositoryDetail(w http.ResponseWriter, r *http.Request) {
|
||||
name := extractRepoName(r)
|
||||
if name == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := grpcContext(r)
|
||||
|
||||
resp, err := s.registry.GetRepository(ctx, &mcrv1.GetRepositoryRequest{Name: name})
|
||||
if err != nil {
|
||||
s.templates.render(w, "repository_detail", map[string]any{
|
||||
"Session": true,
|
||||
"Name": name,
|
||||
"Error": grpcErrorMessage(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.templates.render(w, "repository_detail", map[string]any{
|
||||
"Session": true,
|
||||
"Name": resp.GetName(),
|
||||
"Tags": resp.GetTags(),
|
||||
"Manifests": resp.GetManifests(),
|
||||
"TotalSize": resp.GetTotalSize(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleManifestDetail renders details for a specific manifest.
|
||||
func (s *Server) handleManifestDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// URL format: /repositories/{name}/manifests/{digest}
|
||||
// The name can contain slashes, so we parse manually.
|
||||
path := r.URL.Path
|
||||
const manifestsPrefix = "/manifests/"
|
||||
idx := strings.LastIndex(path, manifestsPrefix)
|
||||
if idx < 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
digest := path[idx+len(manifestsPrefix):]
|
||||
repoPath := path[len("/repositories/"):idx]
|
||||
|
||||
if repoPath == "" || digest == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := grpcContext(r)
|
||||
|
||||
resp, err := s.registry.GetRepository(ctx, &mcrv1.GetRepositoryRequest{Name: repoPath})
|
||||
if err != nil {
|
||||
s.templates.render(w, "manifest_detail", map[string]any{
|
||||
"Session": true,
|
||||
"RepoName": repoPath,
|
||||
"Error": grpcErrorMessage(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find the specific manifest.
|
||||
var manifest *mcrv1.ManifestInfo
|
||||
for _, m := range resp.GetManifests() {
|
||||
if m.GetDigest() == digest {
|
||||
manifest = m
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifest == nil {
|
||||
s.templates.render(w, "manifest_detail", map[string]any{
|
||||
"Session": true,
|
||||
"RepoName": repoPath,
|
||||
"Error": "Manifest not found.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.templates.render(w, "manifest_detail", map[string]any{
|
||||
"Session": true,
|
||||
"RepoName": repoPath,
|
||||
"Manifest": manifest,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePolicies renders the policy list and create form.
|
||||
func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := grpcContext(r)
|
||||
csrf := s.generateCSRFToken(w)
|
||||
|
||||
resp, err := s.policy.ListPolicyRules(ctx, &mcrv1.ListPolicyRulesRequest{})
|
||||
if err != nil {
|
||||
s.templates.render(w, "policies", map[string]any{
|
||||
"Session": true,
|
||||
"CSRFToken": csrf,
|
||||
"Error": grpcErrorMessage(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.templates.render(w, "policies", map[string]any{
|
||||
"Session": true,
|
||||
"CSRFToken": csrf,
|
||||
"Policies": resp.GetRules(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreatePolicy processes the policy creation form.
|
||||
func (s *Server) handleCreatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.validateCSRFToken(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
priority, _ := strconv.ParseInt(r.FormValue("priority"), 10, 32)
|
||||
actions := splitCSV(r.FormValue("actions"))
|
||||
repos := splitCSV(r.FormValue("repositories"))
|
||||
|
||||
ctx := grpcContext(r)
|
||||
|
||||
_, err := s.policy.CreatePolicyRule(ctx, &mcrv1.CreatePolicyRuleRequest{
|
||||
Priority: int32(priority),
|
||||
Description: r.FormValue("description"),
|
||||
Effect: r.FormValue("effect"),
|
||||
Actions: actions,
|
||||
Repositories: repos,
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
csrf := s.generateCSRFToken(w)
|
||||
s.templates.render(w, "policies", map[string]any{
|
||||
"Session": true,
|
||||
"CSRFToken": csrf,
|
||||
"Error": grpcErrorMessage(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleTogglePolicy toggles a policy rule's enabled state.
|
||||
func (s *Server) handleTogglePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.validateCSRFToken(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
idStr := extractPolicyID(r.URL.Path, "/toggle")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid policy ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := grpcContext(r)
|
||||
|
||||
// Get current state.
|
||||
rule, err := s.policy.GetPolicyRule(ctx, &mcrv1.GetPolicyRuleRequest{Id: id})
|
||||
if err != nil {
|
||||
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle the enabled field.
|
||||
_, err = s.policy.UpdatePolicyRule(ctx, &mcrv1.UpdatePolicyRuleRequest{
|
||||
Id: id,
|
||||
Enabled: !rule.GetEnabled(),
|
||||
UpdateMask: []string{"enabled"},
|
||||
// Carry forward required fields.
|
||||
Priority: rule.GetPriority(),
|
||||
Description: rule.GetDescription(),
|
||||
Effect: rule.GetEffect(),
|
||||
Actions: rule.GetActions(),
|
||||
Repositories: rule.GetRepositories(),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleDeletePolicy deletes a policy rule.
|
||||
func (s *Server) handleDeletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB limit
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.validateCSRFToken(r) {
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
idStr := extractPolicyID(r.URL.Path, "/delete")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid policy ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := grpcContext(r)
|
||||
|
||||
_, err = s.policy.DeletePolicyRule(ctx, &mcrv1.DeletePolicyRuleRequest{Id: id})
|
||||
if err != nil {
|
||||
http.Error(w, grpcErrorMessage(err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/policies", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAudit renders the audit log with filters and pagination.
|
||||
func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := grpcContext(r)
|
||||
|
||||
q := r.URL.Query()
|
||||
eventType := q.Get("event_type")
|
||||
repo := q.Get("repository")
|
||||
since := q.Get("since")
|
||||
until := q.Get("until")
|
||||
pageStr := q.Get("page")
|
||||
|
||||
page := 1
|
||||
if pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize int32 = 50
|
||||
offset := int32(page-1) * pageSize
|
||||
|
||||
req := &mcrv1.ListAuditEventsRequest{
|
||||
Pagination: &mcrv1.PaginationRequest{
|
||||
Limit: pageSize + 1, // fetch one extra to detect next page
|
||||
Offset: offset,
|
||||
},
|
||||
EventType: eventType,
|
||||
Repository: repo,
|
||||
Since: since,
|
||||
Until: until,
|
||||
}
|
||||
|
||||
resp, err := s.audit.ListAuditEvents(ctx, req)
|
||||
if err != nil {
|
||||
s.templates.render(w, "audit", map[string]any{
|
||||
"Session": true,
|
||||
"Error": grpcErrorMessage(err),
|
||||
"FilterType": eventType,
|
||||
"FilterRepo": repo,
|
||||
"FilterSince": since,
|
||||
"FilterUntil": until,
|
||||
"Page": page,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
events := resp.GetEvents()
|
||||
hasNext := len(events) > int(pageSize)
|
||||
if hasNext {
|
||||
events = events[:pageSize]
|
||||
}
|
||||
|
||||
// Build pagination URLs.
|
||||
buildURL := func(p int) string {
|
||||
v := url.Values{}
|
||||
if eventType != "" {
|
||||
v.Set("event_type", eventType)
|
||||
}
|
||||
if repo != "" {
|
||||
v.Set("repository", repo)
|
||||
}
|
||||
if since != "" {
|
||||
v.Set("since", since)
|
||||
}
|
||||
if until != "" {
|
||||
v.Set("until", until)
|
||||
}
|
||||
v.Set("page", strconv.Itoa(p))
|
||||
return "/audit?" + v.Encode()
|
||||
}
|
||||
|
||||
s.templates.render(w, "audit", map[string]any{
|
||||
"Session": true,
|
||||
"Events": events,
|
||||
"FilterType": eventType,
|
||||
"FilterRepo": repo,
|
||||
"FilterSince": since,
|
||||
"FilterUntil": until,
|
||||
"Page": page,
|
||||
"HasNext": hasNext,
|
||||
"PrevURL": buildURL(page - 1),
|
||||
"NextURL": buildURL(page + 1),
|
||||
})
|
||||
}
|
||||
|
||||
// renderError renders a template with an error message derived from a gRPC error.
|
||||
func (s *Server) renderError(w http.ResponseWriter, tmpl, fallback string, err error) {
|
||||
msg := fallback
|
||||
if st, ok := status.FromError(err); ok {
|
||||
if st.Code() == codes.PermissionDenied {
|
||||
msg = "Access denied."
|
||||
}
|
||||
}
|
||||
s.templates.render(w, tmpl, map[string]any{
|
||||
"Session": true,
|
||||
"Error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// grpcErrorMessage extracts a human-readable message from a gRPC error.
|
||||
func grpcErrorMessage(err error) string {
|
||||
if st, ok := status.FromError(err); ok {
|
||||
if st.Code() == codes.PermissionDenied {
|
||||
return "Access denied."
|
||||
}
|
||||
return st.Message()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// extractRepoName extracts the repository name from the URL path.
|
||||
// The name may contain slashes (e.g., "library/nginx").
|
||||
// URL format: /repositories/{name...}
|
||||
func extractRepoName(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
prefix := "/repositories/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
name := path[len(prefix):]
|
||||
|
||||
// Strip trailing slash.
|
||||
name = strings.TrimRight(name, "/")
|
||||
|
||||
// If the path contains /manifests/, extract only the repo name part.
|
||||
if idx := strings.Index(name, "/manifests/"); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// extractPolicyID extracts the policy ID from paths like /policies/{id}/toggle
|
||||
// or /policies/{id}/delete.
|
||||
func extractPolicyID(path, suffix string) string {
|
||||
path = strings.TrimSuffix(path, suffix)
|
||||
path = strings.TrimPrefix(path, "/policies/")
|
||||
return path
|
||||
}
|
||||
|
||||
// splitCSV splits a comma-separated string, trimming whitespace.
|
||||
func splitCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user