package webserver import ( "crypto/rand" "fmt" "html/template" "log/slog" "net/http" "github.com/go-chi/chi/v5" "git.wntrmute.dev/mc/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/csrf" mcdsso "git.wntrmute.dev/mc/mcdsl/sso" "git.wntrmute.dev/mc/mcdsl/web" mcqweb "git.wntrmute.dev/mc/mcq/web" "git.wntrmute.dev/mc/mcq/internal/db" "git.wntrmute.dev/mc/mcq/internal/render" ) const cookieName = "mcq_session" // Config holds webserver-specific configuration. type Config struct { ServiceName string Tags []string // SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead // of the direct username/password login form. MciasURL string CACert string RedirectURI string } // Server is the MCQ web UI server. type Server struct { db *db.DB auth *auth.Authenticator csrf *csrf.Protect ssoClient *mcdsso.Client render *render.Renderer logger *slog.Logger config Config } // New creates a web UI server. func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) { csrfSecret := make([]byte, 32) if _, err := rand.Read(csrfSecret); err != nil { return nil, fmt.Errorf("generate CSRF secret: %w", err) } csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token") s := &Server{ db: database, auth: authenticator, csrf: csrfProtect, render: render.New(), logger: logger, config: cfg, } // Create SSO client if the service has an SSO redirect_uri configured. if cfg.RedirectURI != "" { ssoClient, err := mcdsso.New(mcdsso.Config{ MciasURL: cfg.MciasURL, ClientID: "mcq", RedirectURI: cfg.RedirectURI, CACert: cfg.CACert, }) if err != nil { return nil, fmt.Errorf("create SSO client: %w", err) } s.ssoClient = ssoClient logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL) } return s, nil } // RegisterRoutes adds web UI routes to the given router. func (s *Server) RegisterRoutes(r chi.Router) { if s.ssoClient != nil { r.Get("/login", s.handleSSOLogin) r.Get("/sso/redirect", s.handleSSORedirect) r.Get("/sso/callback", s.handleSSOCallback) } else { r.Get("/login", s.handleLoginPage) r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP) } r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP) // Authenticated routes. r.Group(func(r chi.Router) { r.Use(web.RequireAuth(s.auth, cookieName, "/login")) r.Use(s.csrf.Middleware) r.Get("/", s.handleList) r.Get("/d/{slug}", s.handleRead) r.Post("/d/{slug}/read", s.handleMarkRead) r.Post("/d/{slug}/unread", s.handleMarkUnread) r.Post("/d/{slug}/delete", s.handleDelete) r.Post("/logout", s.handleLogout) }) } type pageData struct { Username string Error string Title string Content any SSO bool } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{}, s.csrf.TemplateFunc(w)) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") totpCode := r.FormValue("totp_code") token, _, err := s.auth.Login(username, password, totpCode) if err != nil { web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{Error: "Invalid credentials"}, s.csrf.TemplateFunc(w)) return } web.SetSessionCookie(w, cookieName, token) http.Redirect(w, r, "/", http.StatusFound) } // handleSSOLogin renders a landing page with a "Sign in with MCIAS" button. func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) { web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}, s.csrf.TemplateFunc(w)) } // handleSSORedirect initiates the SSO redirect to MCIAS. func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) { if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); err != nil { s.logger.Error("sso: redirect to login", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) } } // handleSSOCallback exchanges the authorization code for a JWT and sets the session. func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) { token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq") if err != nil { s.logger.Error("sso: callback", "error", err) http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized) return } web.SetSessionCookie(w, cookieName, token) http.Redirect(w, r, returnTo, http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { token := web.GetSessionToken(r, cookieName) if token != "" { _ = s.auth.Logout(token) } web.ClearSessionCookie(w, cookieName) http.Redirect(w, r, "/login", http.StatusFound) } type listData struct { Username string Documents []db.Document } func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { info := auth.TokenInfoFromContext(r.Context()) docs, err := s.db.ListDocuments() if err != nil { s.logger.Error("failed to list documents", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if docs == nil { docs = []db.Document{} } web.RenderTemplate(w, mcqweb.FS, "list.html", listData{ Username: info.Username, Documents: docs, }, s.csrf.TemplateFunc(w)) } type readData struct { Username string Doc db.Document HTML template.HTML } func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) { info := auth.TokenInfoFromContext(r.Context()) slug := chi.URLParam(r, "slug") doc, err := s.db.GetDocument(slug) if err != nil { http.NotFound(w, r) return } html, err := s.render.Render([]byte(doc.Body)) if err != nil { s.logger.Error("failed to render markdown", "slug", slug, "error", err) http.Error(w, "render error", http.StatusInternalServerError) return } web.RenderTemplate(w, mcqweb.FS, "read.html", readData{ Username: info.Username, Doc: *doc, HTML: template.HTML(html), //nolint:gosec // markdown rendered by goldmark, not user-controlled injection }, s.csrf.TemplateFunc(w)) } func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if _, err := s.db.MarkRead(slug); err != nil { s.logger.Error("failed to mark read", "slug", slug, "error", err) } http.Redirect(w, r, "/", http.StatusFound) } func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if _, err := s.db.MarkUnread(slug); err != nil { s.logger.Error("failed to mark unread", "slug", slug, "error", err) } http.Redirect(w, r, "/", http.StatusFound) } func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") if err := s.db.DeleteDocument(slug); err != nil { s.logger.Error("failed to delete document", "slug", slug, "error", err) } http.Redirect(w, r, "/", http.StatusFound) }