Go templates require all referenced functions to be defined at parse time, even in branches that won't execute. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
245 lines
6.9 KiB
Go
245 lines
6.9 KiB
Go
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)
|
|
}
|