209 lines
7.7 KiB
Go
209 lines
7.7 KiB
Go
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})
|
|
}
|