Files
mailgosend/internal/webclient/server.go
T
2026-05-22 06:06:44 +00:00

282 lines
7.8 KiB
Go

// 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])
}