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:
136
internal/webserver/templates.go
Normal file
136
internal/webserver/templates.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcr/web"
|
||||
)
|
||||
|
||||
// templateSet wraps parsed templates and provides a render method.
|
||||
type templateSet struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
// templateFuncs returns the function map used across all templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatSize": formatSize,
|
||||
"formatTime": formatTime,
|
||||
"truncate": truncate,
|
||||
"joinStrings": joinStrings,
|
||||
}
|
||||
}
|
||||
|
||||
// loadTemplates parses all page templates with the layout template.
|
||||
func loadTemplates() (*templateSet, error) {
|
||||
// Read layout template.
|
||||
layoutBytes, err := fs.ReadFile(web.Content, "templates/layout.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webserver: read layout template: %w", err)
|
||||
}
|
||||
layoutStr := string(layoutBytes)
|
||||
|
||||
pages := []string{
|
||||
"login",
|
||||
"dashboard",
|
||||
"repositories",
|
||||
"repository_detail",
|
||||
"manifest_detail",
|
||||
"policies",
|
||||
"audit",
|
||||
}
|
||||
|
||||
ts := &templateSet{
|
||||
templates: make(map[string]*template.Template, len(pages)),
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
pageBytes, readErr := fs.ReadFile(web.Content, "templates/"+page+".html")
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("webserver: read template %s: %w", page, readErr)
|
||||
}
|
||||
|
||||
t, parseErr := template.New("layout").Funcs(templateFuncs()).Parse(layoutStr)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("webserver: parse layout for %s: %w", page, parseErr)
|
||||
}
|
||||
|
||||
_, parseErr = t.Parse(string(pageBytes))
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("webserver: parse template %s: %w", page, parseErr)
|
||||
}
|
||||
|
||||
ts.templates[page] = t
|
||||
}
|
||||
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// render executes a named template and writes the result to w.
|
||||
func (ts *templateSet) render(w http.ResponseWriter, name string, data any) {
|
||||
t, ok := ts.templates[name]
|
||||
if !ok {
|
||||
http.Error(w, "template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.Execute(w, data); err != nil {
|
||||
// Template already started writing; log but don't send another error.
|
||||
_ = err // best-effort; headers may already be sent
|
||||
}
|
||||
}
|
||||
|
||||
// formatSize converts bytes to a human-readable string.
|
||||
func formatSize(b int64) string {
|
||||
const (
|
||||
kib = 1024
|
||||
mib = 1024 * kib
|
||||
gib = 1024 * mib
|
||||
tib = 1024 * gib
|
||||
)
|
||||
|
||||
switch {
|
||||
case b >= tib:
|
||||
return fmt.Sprintf("%.1f TiB", float64(b)/float64(tib))
|
||||
case b >= gib:
|
||||
return fmt.Sprintf("%.1f GiB", float64(b)/float64(gib))
|
||||
case b >= mib:
|
||||
return fmt.Sprintf("%.1f MiB", float64(b)/float64(mib))
|
||||
case b >= kib:
|
||||
return fmt.Sprintf("%.1f KiB", float64(b)/float64(kib))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
}
|
||||
|
||||
// formatTime converts an RFC3339 string to a more readable format.
|
||||
func formatTime(s string) string {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
// Try RFC3339Nano.
|
||||
t, err = time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// truncate returns the first n characters of s, appending "..." if truncated.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// joinStrings joins string slices for template rendering.
|
||||
func joinStrings(ss []string, sep string) string {
|
||||
return strings.Join(ss, sep)
|
||||
}
|
||||
Reference in New Issue
Block a user