Files
mcr/internal/webserver/handlers.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

458 lines
12 KiB
Go

package webserver
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
mcrv1 "git.wntrmute.dev/mc/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
}