package api import ( "encoding/json" "net/http" "time" "github.com/ponzischeme89/memby/server/internal/adminevents" "github.com/ponzischeme89/memby/server/internal/buildinfo" "github.com/ponzischeme89/memby/server/internal/store" ) // adminGatewaySettingsResponse is deliberately three things at once: what the operator has // chosen, what the container was started with, and what is therefore in force. A settings // page that showed only the first would leave every empty field looking like a value of // nothing, when an empty field here means "whatever .env says" — and the operator has no // other way to see what that is without opening a file on the NAS. type adminGatewaySettingsResponse struct { Settings store.GatewaySettings `json:"settings"` Deployed deployedGatewaySettings `json:"deployed"` Effective deployedGatewaySettings `json:"effective"` // LogLevels is the vocabulary rather than a list the console keeps its own copy of, // for the reason the preference catalogue is served with the accounts page: a value // the server would refuse must never be offerable. LogLevels []string `json:"logLevels"` // Version and Timezone name the process this page is about, so the page can identify // the gateway it is changing without a second request. Version string `json:"version"` } func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse { settings := s.gatewaySettings.get() return adminGatewaySettingsResponse{ Settings: settings, Deployed: s.deployedSettings(), Effective: deployedGatewaySettings{ Timezone: s.householdTimezoneName(), LogLevel: s.effectiveLogLevel(), SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)), SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute), RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute), EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second), }, LogLevels: store.GatewayLogLevels, Version: buildinfo.Version(), } } func (s *Server) effectiveLogLevel() string { if s.logLevel != nil { return levelName(s.logLevel.Level()) } return levelName(s.deployedLogLevel) } // handleAdminGatewaySettings serves the page and accepts a write on the same path, which // is the shape the *arr policy routes already take. func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { writeJSON(w, http.StatusOK, s.gatewaySettingsResponse()) return } var req store.GatewaySettings if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } previous := s.gatewaySettings.get() _, operator, _ := s.browserSession(r, adminSessionPurpose) req.UpdatedBy = operator stored, err := s.store.SetGatewaySettings(r.Context(), req) if err != nil { s.loggerFor(r.Context()).Error("gateway settings write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save gateway settings") return } // Applied here rather than waited for: the watcher would pick this up within thirty // seconds, and an operator who has just turned debug logging on and gone to look at // the log would spend that half minute believing it had not worked. s.gatewaySettings.set(stored) s.applyLogLevel(stored.LogLevel) s.loggerFor(r.Context()).Info("gateway settings changed", "timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(), "session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)), "operator", operator) if summary := gatewaySettingsChanges(previous, stored); summary != "" { s.publishAdmin(r.Context(), adminevents.Event{ Type: adminevents.TypeSettingsChanged, Severity: adminevents.SeverityWarning, Title: "Gateway settings changed", Summary: summary, Actor: operator, Link: "/admin/settings", }) } writeJSON(w, http.StatusOK, s.gatewaySettingsResponse()) } // gatewaySettingsChanges is what the notification says, and returns empty when nothing // moved. The console re-posts the whole document, so a save that changed nothing is an // ordinary event — the preferenceChanges rule, for the same reason: a feed that records // every press of Save is one nobody reads. func gatewaySettingsChanges(before, after store.GatewaySettings) string { changes := []string{} if before.Timezone != after.Timezone { changes = append(changes, "timezone") } if before.LogLevel != after.LogLevel { changes = append(changes, "log level") } if before.SessionIdleDays != after.SessionIdleDays { changes = append(changes, "session expiry") } if before.SonarrAlertMinutes != after.SonarrAlertMinutes { changes = append(changes, "episode alert window") } if before.RadarrAlertMinutes != after.RadarrAlertMinutes { changes = append(changes, "film alert window") } if before.EmbyHealthSeconds != after.EmbyHealthSeconds { changes = append(changes, "Emby health probe") } switch len(changes) { case 0: return "" case 1: return "The gateway's " + changes[0] + " was changed" default: return "The gateway's " + joinPhrase(changes) + " were changed" } } func joinPhrase(values []string) string { switch len(values) { case 0: return "" case 1: return values[0] } out := "" for index, value := range values[:len(values)-1] { if index > 0 { out += ", " } out += value } return out + " and " + values[len(values)-1] }