283 lines
7.9 KiB
Go
283 lines
7.9 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("GET /mail/{boxid}/{uid}/attachment/{n}", s.require(s.messageAttachment))
|
|
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", "DENY")
|
|
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])
|
|
}
|