// Package webserver implements the standalone web UI server for Metacrypt. // It communicates with the vault over gRPC and renders server-side HTML. package webserver import ( "context" "crypto/tls" "fmt" "html/template" "io/fs" "log/slog" "net/http" "time" "github.com/go-chi/chi/v5" "git.wntrmute.dev/kyle/metacrypt/internal/config" webui "git.wntrmute.dev/kyle/metacrypt/web" ) // WebServer is the standalone web UI server. type WebServer struct { cfg *config.Config vault *VaultClient logger *slog.Logger httpSrv *http.Server staticFS fs.FS } // New creates a new WebServer. It dials the vault gRPC endpoint. func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) { vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert) if err != nil { return nil, fmt.Errorf("webserver: connect to vault: %w", err) } staticFS, err := fs.Sub(webui.FS, "static") if err != nil { return nil, fmt.Errorf("webserver: static FS: %w", err) } return &WebServer{ cfg: cfg, vault: vault, logger: logger, staticFS: staticFS, }, nil } // Start starts the web server. It blocks until the server is closed. func (ws *WebServer) Start() error { r := chi.NewRouter() ws.registerRoutes(r) ws.httpSrv = &http.Server{ Addr: ws.cfg.Web.ListenAddr, Handler: r, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } ws.logger.Info("starting web server", "addr", ws.cfg.Web.ListenAddr) if ws.cfg.Web.TLSCert != "" && ws.cfg.Web.TLSKey != "" { ws.httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12} err := ws.httpSrv.ListenAndServeTLS(ws.cfg.Web.TLSCert, ws.cfg.Web.TLSKey) if err != nil && err != http.ErrServerClosed { return fmt.Errorf("webserver: %w", err) } return nil } err := ws.httpSrv.ListenAndServe() if err != nil && err != http.ErrServerClosed { return fmt.Errorf("webserver: %w", err) } return nil } // Shutdown gracefully shuts down the web server. func (ws *WebServer) Shutdown(ctx context.Context) error { _ = ws.vault.Close() if ws.httpSrv != nil { return ws.httpSrv.Shutdown(ctx) } return nil } func (ws *WebServer) renderTemplate(w http.ResponseWriter, name string, data interface{}) { tmpl, err := template.ParseFS(webui.FS, "templates/layout.html", "templates/"+name, ) if err != nil { ws.logger.Error("parse template", "name", name, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { ws.logger.Error("execute template", "name", name, "error", err) } } func extractCookie(r *http.Request) string { c, err := r.Cookie("metacrypt_token") if err != nil { return "" } return c.Value }