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 }