package webserver import ( "crypto/tls" "database/sql" "fmt" "html/template" "io/fs" "log/slog" "net/http" "sync" "time" "git.wntrmute.dev/kyle/eng-pad-server/internal/auth" "git.wntrmute.dev/kyle/eng-pad-server/web" "github.com/go-chi/chi/v5" "github.com/go-webauthn/webauthn/webauthn" ) type Config struct { Addr string DB *sql.DB BaseURL string TLSCert string TLSKey string RPDisplayName string RPID string RPOrigins []string } type WebServer struct { db *sql.DB baseURL string tmpls map[string]*template.Template webauthn *webauthn.WebAuthn mu sync.Mutex sessions map[string]*webauthn.SessionData } func Start(cfg Config) (*http.Server, error) { templateFS, err := fs.Sub(web.Content, "templates") if err != nil { return nil, fmt.Errorf("template fs: %w", err) } layoutData, err := fs.ReadFile(templateFS, "layout.html") if err != nil { return nil, fmt.Errorf("read layout: %w", err) } pages := []string{"login.html", "notebooks.html", "notebook.html", "page.html", "keys.html"} tmpls := make(map[string]*template.Template, len(pages)) for _, page := range pages { t, err := template.New("layout.html").Parse(string(layoutData)) if err != nil { return nil, fmt.Errorf("parse layout: %w", err) } pageData, err := fs.ReadFile(templateFS, page) if err != nil { return nil, fmt.Errorf("read %s: %w", page, err) } if _, err := t.Parse(string(pageData)); err != nil { return nil, fmt.Errorf("parse %s: %w", page, err) } tmpls[page] = t } ws := &WebServer{ db: cfg.DB, baseURL: cfg.BaseURL, tmpls: tmpls, sessions: make(map[string]*webauthn.SessionData), } if cfg.RPID != "" { wa, err := auth.NewWebAuthn(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins) if err != nil { return nil, fmt.Errorf("init webauthn: %w", err) } ws.webauthn = wa slog.Info("WebAuthn enabled", "rpid", cfg.RPID) } r := chi.NewRouter() // Static files staticFS, _ := fs.Sub(web.Content, "static") r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // Public routes r.Get("/login", ws.handleLoginPage) r.Post("/login", ws.handleLoginSubmit) // WebAuthn public routes (login ceremony) r.Post("/webauthn/login/begin", ws.handleWebAuthnLoginBegin) r.Post("/webauthn/login/finish", ws.handleWebAuthnLoginFinish) // Share routes (no auth) r.Get("/s/{token}", ws.handleShareNotebook) r.Get("/s/{token}/pages/{num}", ws.handleSharePage) r.Get("/s/{token}/pages/{num}/svg", ws.handleSharePageSVG) r.Get("/s/{token}/pages/{num}/jpg", ws.handleSharePageJPG) r.Get("/s/{token}/pdf", ws.handleSharePDF) // Authenticated routes r.Group(func(r chi.Router) { r.Use(ws.authMiddleware) r.Get("/", http.RedirectHandler("/notebooks", http.StatusFound).ServeHTTP) r.Get("/notebooks", ws.handleNotebooks) r.Get("/notebooks/{id}", ws.handleNotebook) r.Get("/notebooks/{id}/pages/{num}", ws.handlePage) r.Get("/notebooks/{id}/pages/{num}/svg", ws.handlePageSVG) r.Get("/notebooks/{id}/pages/{num}/jpg", ws.handlePageJPG) r.Get("/notebooks/{id}/pdf", ws.handleNotebookPDF) r.Post("/notebooks/{id}/delete", ws.handleDeleteNotebook) r.Post("/notebooks/{id}/share", ws.handleCreateShare) r.Post("/notebooks/{id}/share/revoke", ws.handleRevokeShare) r.Get("/logout", ws.handleLogout) // WebAuthn authenticated routes (registration + key management) r.Post("/webauthn/register/begin", ws.handleWebAuthnRegisterBegin) r.Post("/webauthn/register/finish", ws.handleWebAuthnRegisterFinish) r.Get("/keys", ws.handleKeysList) r.Post("/keys/delete", ws.handleKeyDelete) }) srv := &http.Server{ Addr: cfg.Addr, Handler: r, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } if cfg.TLSCert != "" && cfg.TLSKey != "" { tlsCert, err := tls.LoadX509KeyPair(cfg.TLSCert, cfg.TLSKey) if err != nil { return nil, fmt.Errorf("load TLS cert: %w", err) } srv.TLSConfig = &tls.Config{ Certificates: []tls.Certificate{tlsCert}, MinVersion: tls.VersionTLS13, } slog.Info("web UI started", "addr", cfg.Addr, "tls", true) go func() { _ = srv.ListenAndServeTLS("", "") }() } else { slog.Info("web UI started", "addr", cfg.Addr, "tls", false) go func() { _ = srv.ListenAndServe() }() } return srv, nil }