Files
mcq/internal/webserver/server.go
Kyle Isom ed3a547e54 Add unqueue (delete) button to web reading view
Adds a delete route and handler to the web UI so documents can be
removed directly from the reading page. Uses CSRF-protected POST with a
browser confirmation dialog. Styled with a danger accent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:08:45 -07:00

188 lines
4.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"
"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
}
// Server is the MCQ web UI server.
type Server struct {
db *db.DB
auth *auth.Authenticator
csrf *csrf.Protect
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")
return &Server{
db: database,
auth: authenticator,
csrf: csrfProtect,
render: render.New(),
logger: logger,
config: cfg,
}, nil
}
// RegisterRoutes adds web UI routes to the given router.
func (s *Server) RegisterRoutes(r chi.Router) {
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
}
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)
}
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)
}