Files
mailgoserver/internal/webui/settings.go
T

209 lines
7.7 KiB
Go
Raw Normal View History

2026-08-12 12:56:22 +01:00
package webui
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
var timezoneNames = []string{
"UTC", "Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Madrid", "Europe/Rome",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Dubai", "Australia/Sydney",
}
// settingsPage mirrors settings.py's settings().
func (a *App) settingsPage(w http.ResponseWriter, r *http.Request) {
sections := M{}
for _, name := range a.Cfg.SectionStrings() {
sec := a.Cfg.Section(name)
kv := M{}
for _, k := range sec.Keys() {
kv[strings.ToLower(k.Name())] = k.Value()
}
sections[name] = kv
}
a.render(w, r, "settings.html", M{"active": "settings", "settings": sections, "timezones": timezoneNames})
}
// settingsUpdate mirrors settings.py's settings_update(): iterate every existing
// Section.key, update from the matching form field if present and different, write
// settings.ini back out. Preserves the '""'-means-empty convention for server_banner.
func (a *App) settingsUpdate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
2026-08-13 08:07:19 +01:00
// Turning on enforce_admin_mfa immediately blocks every admin route except
// /account for any admin without MFA configured — including /settings itself.
// Without this precondition, an admin who enables enforcement before setting up
// their own MFA would lock themselves out with no way back in (short of editing
// the database directly), since requireAuth's gate applies to this very handler.
if r.FormValue("Auth.enforce_admin_mfa") == "true" && a.Cfg.Section("Auth").Key("enforce_admin_mfa").Value() != "true" {
user := userFromContext(r)
hasMFA := user.TOTPEnabled
if !hasMFA {
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
hasMFA = true
}
}
if !hasMFA {
setFlash(w, "error", "Set up your own two-factor authentication (see Account) before enforcing it for all admins — otherwise you'd lock yourself out.")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
}
2026-08-12 12:56:22 +01:00
changed := false
for _, name := range a.Cfg.SectionStrings() {
sec := a.Cfg.Section(name)
for _, k := range sec.Keys() {
2026-08-13 07:03:40 +01:00
// Form field names are always lowercase (matches settingsPage's
// strings.ToLower(k.Name()) template keys), but ini key names keep
// whatever case the defaults table used (e.g. "SMTP_PORT") — comparing
// k.Name() directly here meant every uppercase-defined key silently
// never saved.
field := name + "." + strings.ToLower(k.Name())
2026-08-12 12:56:22 +01:00
if !r.Form.Has(field) {
continue
}
val := r.FormValue(field)
if name == "Server" && strings.EqualFold(k.Name(), "server_banner") && strings.TrimSpace(val) == "" {
val = `""`
}
if val != k.Value() {
k.SetValue(val)
changed = true
}
}
}
if !changed {
setFlash(w, "info", "No changes were made")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
setFlash(w, "error", "Error saving settings: "+err.Error())
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
return
}
setFlash(w, "success", "Settings saved. Restart the server for changes to take effect.")
http.Redirect(w, r, Prefix+"/settings", http.StatusFound)
}
// testDatabaseConnection mirrors settings.py's test_database_connection_endpoint,
// backed by a Go equivalent of database.py's test_database_connection. Unlike the
// Python version (which pip-installs a driver at request time for mysql/postgresql/
// mssql), this build only compiles in the sqlite driver — other schemes report clearly
// as unsupported rather than attempting a live package install from a web request.
func (a *App) testDatabaseConnection(w http.ResponseWriter, r *http.Request) {
var body struct {
URL string `json:"url"`
}
if err := decodeJSONBody(r, &body); err != nil || body.URL == "" {
writeJSON(w, http.StatusOK, M{"status": "error", "message": "No database URL provided"})
return
}
switch {
case strings.HasPrefix(body.URL, "sqlite:///"):
path := strings.TrimPrefix(body.URL, "sqlite:///")
db, err := sql.Open("sqlite", path)
if err != nil {
writeJSON(w, http.StatusOK, M{"status": "error", "message": err.Error()})
return
}
defer db.Close()
if err := db.Ping(); err != nil {
writeJSON(w, http.StatusOK, M{"status": "error", "message": err.Error()})
return
}
writeJSON(w, http.StatusOK, M{"status": "success", "message": "SQLite connection successful"})
case strings.HasPrefix(body.URL, "mysql://"), strings.HasPrefix(body.URL, "postgresql://"), strings.HasPrefix(body.URL, "mssql"):
writeJSON(w, http.StatusOK, M{"status": "error", "message": "This database type isn't compiled into this build"})
default:
writeJSON(w, http.StatusOK, M{"status": "error", "message": "Unrecognized database URL scheme"})
}
}
func decodeJSONBody(r *http.Request, v any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(v)
}
// uploadCert mirrors settings.py's upload_cert().
func (a *App) uploadCert(w http.ResponseWriter, r *http.Request) {
a.uploadTLSFile(w, r, "cert_file", "crt")
}
// uploadKey mirrors settings.py's upload_key().
func (a *App) uploadKey(w http.ResponseWriter, r *http.Request) {
a.uploadTLSFile(w, r, "key_file", "key")
}
func (a *App) uploadTLSFile(w http.ResponseWriter, r *http.Request, field, forcedExt string) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid upload"})
return
}
file, header, err := r.FormFile(field)
if err != nil {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "No file provided"})
return
}
defer file.Close()
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(header.Filename), "."))
if ext != "crt" && ext != "key" && ext != "pem" {
writeJSON(w, http.StatusBadRequest, M{"status": "error", "message": "Invalid file extension"})
return
}
sslDir := filepath.Join(filepath.Dir(a.ConfigPath), "ssl_certs")
os.MkdirAll(sslDir, 0o755)
filePath := filepath.Join(sslDir, fmt.Sprintf("server%d.%s", time.Now().Unix(), forcedExt))
out, err := os.Create(filePath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"})
return
}
defer out.Close()
if _, err := out.ReadFrom(file); err != nil {
writeJSON(w, http.StatusInternalServerError, M{"status": "error", "message": "Could not save file"})
return
}
writeJSON(w, http.StatusOK, M{"status": "success", "filepath": filePath})
}
// getServerIP mirrors settings.py's get_server_ip().
func (a *App) getServerIP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, M{"status": "success", "ip": getPublicIP(a.Cfg)})
}
// testAttachmentsPath mirrors settings.py's test_attachments_path().
func (a *App) testAttachmentsPath(w http.ResponseWriter, r *http.Request) {
path := r.FormValue("path")
if path == "" {
writeJSON(w, http.StatusOK, M{"success": false, "message": "No path provided"})
return
}
if !filepath.IsAbs(path) {
path = filepath.Join(filepath.Dir(a.ConfigPath), path)
}
if err := os.MkdirAll(path, 0o755); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": err.Error()})
return
}
testFile := filepath.Join(path, ".write_test")
if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "Path is not writable: " + err.Error()})
return
}
os.Remove(testFile)
writeJSON(w, http.StatusOK, M{"success": true, "message": "Path is valid and writable", "absolute_path": path})
}