// Package webclient provides the webmail HTTP client (default: 0.0.0.0:8080). package webclient import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "html/template" "io/fs" "log" "net/http" "time" "ghb.freebede.com/nahakubuilder/mailgosend/internal/auth" "ghb.freebede.com/nahakubuilder/mailgosend/internal/config" appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" "ghb.freebede.com/nahakubuilder/mailgosend/internal/storage" ) // Deps groups all dependencies for the webclient. type Deps struct { DB *db.DB Crypt *appCrypto.Crypto Sessions *auth.SessionStore Brute *auth.BruteGuard Store *storage.Store Cfg *config.Config FS fs.FS // embed.FS sub-rooted at web/client } // Server handles all webclient HTTP routes. type Server struct { deps *Deps mux *http.ServeMux } // New creates a webclient Server and registers all routes. func New(deps *Deps) *Server { s := &Server{deps: deps, mux: http.NewServeMux()} s.setupRoutes() return s } // Handler returns the HTTP handler. func (s *Server) Handler() http.Handler { return logMiddleware(s.mux) } // ---- Routing ---- func (s *Server) setupRoutes() { m := s.mux // Public m.HandleFunc("GET /login", s.loginGet) m.HandleFunc("POST /login", s.loginPost) m.HandleFunc("GET /login/mfa", s.mfaGet) m.HandleFunc("POST /login/mfa", s.mfaPost) m.HandleFunc("GET /logout", s.logout) // Root redirect m.HandleFunc("GET /{$}", s.require(s.rootRedirect)) // Mailbox + message routes m.HandleFunc("GET /mail/{boxid}", s.require(s.mailboxView)) m.HandleFunc("GET /mail/{boxid}/{uid}", s.require(s.messageView)) m.HandleFunc("POST /mail/{boxid}/{uid}/flag", s.require(s.messageFlag)) m.HandleFunc("POST /mail/{boxid}/{uid}/trash", s.require(s.messageTrash)) m.HandleFunc("POST /mail/{boxid}/{uid}/move", s.require(s.messageMove)) m.HandleFunc("POST /mail/{boxid}/expunge", s.require(s.mailboxExpunge)) // Compose m.HandleFunc("GET /compose", s.require(s.composeGet)) m.HandleFunc("POST /compose", s.require(s.composeSend)) // Settings m.HandleFunc("GET /settings", s.require(s.settingsGet)) m.HandleFunc("POST /settings/password", s.require(s.settingsPassword)) m.HandleFunc("POST /settings/display", s.require(s.settingsDisplay)) m.HandleFunc("GET /settings/mfa/enroll", s.require(s.mfaEnrollGet)) m.HandleFunc("POST /settings/mfa/enroll", s.require(s.mfaEnrollPost)) m.HandleFunc("POST /settings/mfa/disable", s.require(s.mfaDisable)) // Static assets static, _ := fs.Sub(s.deps.FS, "static") m.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) } // ---- Middleware ---- func (s *Server) require(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.currentUser(r) == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } next(w, r) } } func (s *Server) currentUser(r *http.Request) *models.User { _, user, err := s.deps.Sessions.Get(r) if err != nil || user == nil || !user.Enabled { return nil } return user } func logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("[webmail] %s %s %s", r.Method, r.URL.Path, time.Since(start)) }) } // ---- Template rendering ---- type basePage struct { Flash string Error string CSRF string User *models.User Mailboxes []*models.Mailbox CurrentBoxID int64 // 0 = no mailbox selected (compose, settings, etc.) } func (s *Server) newBase(r *http.Request, flash, errMsg string) basePage { user := s.currentUser(r) sessObj, _, _ := s.deps.Sessions.Get(r) var csrf string if sessObj != nil { csrf = s.csrfToken(sessObj.TokenHash) } var boxes []*models.Mailbox if user != nil { boxes, _ = s.deps.DB.ListMailboxes(r.Context(), user.ID) } return basePage{Flash: flash, Error: errMsg, CSRF: csrf, User: user, Mailboxes: boxes} } func (s *Server) render(w http.ResponseWriter, page string, data any) { t, err := template.New("").Funcs(tmplFuncs).ParseFS( s.deps.FS, "templates/base.html", "templates/"+page+".html", ) if err != nil { log.Printf("[webmail] template parse %s: %v", page, err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "SAMEORIGIN") if err := t.ExecuteTemplate(w, "base", data); err != nil { log.Printf("[webmail] template exec %s: %v", page, err) } } // redirect sends 303 with optional flash/error query param. func redirect(w http.ResponseWriter, r *http.Request, path, flash, errMsg string) { target := path if flash != "" { target += "?flash=" + template.URLQueryEscaper(flash) } else if errMsg != "" { target += "?error=" + template.URLQueryEscaper(errMsg) } http.Redirect(w, r, target, http.StatusSeeOther) } func flashFrom(r *http.Request) (flash, errMsg string) { return r.URL.Query().Get("flash"), r.URL.Query().Get("error") } // ---- CSRF ---- // csrfToken returns an HMAC-SHA256 token valid for the current clock-hour. func (s *Server) csrfToken(sessionHash string) string { return computeCSRF(sessionHash, s.deps.Cfg.SessionSecret, time.Now().UTC()) } func computeCSRF(sessionHash string, secret []byte, t time.Time) string { h := hmac.New(sha256.New, secret) h.Write([]byte(sessionHash)) h.Write([]byte(t.Format("2006-01-02-15"))) return hex.EncodeToString(h.Sum(nil)) } // checkCSRF validates the CSRF token from the form field "_csrf". func (s *Server) checkCSRF(r *http.Request, sessionHash string) bool { got := r.FormValue("_csrf") if got == "" { return false } now := time.Now().UTC() cur := computeCSRF(sessionHash, s.deps.Cfg.SessionSecret, now) if hmac.Equal([]byte(got), []byte(cur)) { return true } prev := computeCSRF(sessionHash, s.deps.Cfg.SessionSecret, now.Add(-time.Hour)) return hmac.Equal([]byte(got), []byte(prev)) } // validateCSRF checks CSRF and writes 403 on failure. Returns false on failure. func (s *Server) validateCSRF(w http.ResponseWriter, r *http.Request) bool { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return false } sess, _, _ := s.deps.Sessions.Get(r) if sess == nil { http.Error(w, "unauthenticated", http.StatusForbidden) return false } if !s.checkCSRF(r, sess.TokenHash) { http.Error(w, "CSRF validation failed", http.StatusForbidden) return false } return true } // ---- Template funcs ---- var tmplFuncs = template.FuncMap{ "humanBytes": humanBytes, "shortTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, "shortDate": func(t time.Time) string { now := time.Now() if t.Year() == now.Year() && t.Month() == now.Month() && t.Day() == now.Day() { return t.Format("15:04") } return t.Format("Jan 2") }, "isZero": func(t time.Time) bool { return t.IsZero() }, "add": func(a, b int) int { return a + b }, "truncate": func(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "..." }, "mailboxLabel": mailboxLabel, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, //nolint:gosec } func mailboxLabel(mboxType string) string { switch mboxType { case "inbox": return "Inbox" case "sent": return "Sent" case "drafts": return "Drafts" case "trash": return "Trash" case "spam": return "Spam" case "archive": return "Archive" } return "" } func humanBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) }