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 } // 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 } } changed := false for _, name := range a.Cfg.SectionStrings() { sec := a.Cfg.Section(name) for _, k := range sec.Keys() { // 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()) 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}) }