package api import ( "encoding/json" "net/http" "strconv" "strings" "archivdms/internal/audit" "archivdms/internal/dateformat" "archivdms/internal/storage" "archivdms/internal/userstore" ) // resolveTenantSettingsTenant determines which tenant the request targets, // mirroring resolveLDAPTenant: domain_admin is pinned to its own signed // session tenant (IDOR-safe, query params ignored); superadmin (no session // tenant) must pass ?tenant_id=. Returns the tenant ID and false when the // request is not authorised or the tenant cannot be determined (the caller // has already written the response). func (s *Server) resolveTenantSettingsTenant(w http.ResponseWriter, r *http.Request) (int64, bool) { sess := sessionFromCtx(r.Context()) if sess.Role == userstore.RoleSuperAdmin { q := r.URL.Query().Get("tenant_id") if q == "" { writeError(w, http.StatusBadRequest, "tenant_id query parameter required for superadmin") return 0, false } tid, err := strconv.ParseInt(q, 10, 64) if err != nil || tid <= 0 { writeError(w, http.StatusBadRequest, "invalid tenant_id") return 0, false } return tid, true } if sess.TenantID == nil { writeError(w, http.StatusForbidden, "no tenant context") return 0, false } return *sess.TenantID, true } // availableScanTitleFormats returns the example token patterns the frontend can // offer as clickable starting points. These are suggestions only — any free // token pattern is accepted (see dateformat.Translate) — not a validation // constraint. Returned as a fresh slice so callers never share backing storage. func availableScanTitleFormats() []string { out := make([]string, len(exampleScanTitleFormats)) copy(out, exampleScanTitleFormats) return out } type tenantSettingsResponse struct { ScanTitleDateFormat string `json:"scan_title_date_format"` ScanTitlePrefix string `json:"scan_title_prefix"` // DefaultTitleTemplate is the tenant-wide fallback title template (Go // text/template) used when an applied classification template carries no // own title_template. Empty string means "no tenant default". DefaultTitleTemplate string `json:"default_title_template"` // AvailableFormats is a list of example patterns (suggestions), not an // allow-list; the field name is kept for frontend compatibility. AvailableFormats []string `json:"available_formats"` } // effectiveScanTitlePrefix returns the tenant's stored prefix, or the default // for empty/legacy rows, so the response always reflects what would actually be // used at upload time. func effectiveScanTitlePrefix(stored string) string { if p := strings.TrimSpace(stored); p != "" { return p } return defaultScanTitlePrefix } // effectiveScanTitleFormat returns the tenant's stored token pattern, or the // default for empty/invalid rows, so the response always reflects the pattern // that would actually be used at upload time. func effectiveScanTitleFormat(stored string) string { if s := strings.TrimSpace(stored); s != "" { if _, err := dateformat.Translate(s); err == nil { return s } } return defaultScanTitleDateFormat } // handleGetTenantSettings returns the tenant's own settings (currently the // placeholder-title date format) plus a list of example format patterns // (suggestions for the frontend, not an allow-list). func (s *Server) handleGetTenantSettings(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not configured") return } tenantID, ok := s.resolveTenantSettingsTenant(w, r) if !ok { return } t, err := s.tenantStore.GetByID(r.Context(), tenantID) if err != nil || t == nil { writeError(w, http.StatusInternalServerError, "load tenant settings failed") return } writeJSON(w, http.StatusOK, tenantSettingsResponse{ ScanTitleDateFormat: effectiveScanTitleFormat(t.ScanTitleDateFormat), ScanTitlePrefix: effectiveScanTitlePrefix(t.ScanTitlePrefix), DefaultTitleTemplate: t.DefaultTitleTemplate, AvailableFormats: availableScanTitleFormats(), }) } // updateTenantSettingsRequest uses pointer fields for PATCH-like semantics: // only the fields present in the JSON body are updated, so the two settings // can be changed independently of one another. type updateTenantSettingsRequest struct { ScanTitleDateFormat *string `json:"scan_title_date_format"` ScanTitlePrefix *string `json:"scan_title_prefix"` DefaultTitleTemplate *string `json:"default_title_template"` } // handleUpdateTenantSettings persists the tenant's placeholder-title date // format. The submitted value is a free token pattern (e.g. "DD.MM.YYYY HH:mm") // validated via dateformat.Translate; an unparseable pattern yields 400 with // the translator's error message. Every attempt — success or failure — is // audit-logged (GoBD). func (s *Server) handleUpdateTenantSettings(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not configured") return } tenantID, ok := s.resolveTenantSettingsTenant(w, r) if !ok { return } logFail := func(detail string) { tid := tenantID s.audlog.Log(audit.Entry{ EventType: audit.EventTenantMgmt, Username: sess.Username, TenantID: &tid, Success: false, Detail: detail, }) } var req updateTenantSettingsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logFail("tenant_settings invalid_body") writeError(w, http.StatusBadRequest, "invalid request body") return } if req.ScanTitleDateFormat == nil && req.ScanTitlePrefix == nil && req.DefaultTitleTemplate == nil { logFail("tenant_settings no_fields") writeError(w, http.StatusBadRequest, "no settings provided") return } // Validate everything before persisting anything, so a bad prefix can never // leave a half-applied update. if req.ScanTitleDateFormat != nil { if _, err := dateformat.Translate(*req.ScanTitleDateFormat); err != nil { logFail("tenant_settings invalid_scan_title_date_format:" + *req.ScanTitleDateFormat) writeError(w, http.StatusBadRequest, err.Error()) return } } if req.ScanTitlePrefix != nil && strings.TrimSpace(*req.ScanTitlePrefix) == "" { logFail("tenant_settings empty_scan_title_prefix") writeError(w, http.StatusBadRequest, "scan_title_prefix must not be empty") return } // An empty default_title_template is allowed (clears the tenant default); // a non-empty one must parse as a valid Go text/template. if req.DefaultTitleTemplate != nil { if err := storage.ValidateTitleTemplate(*req.DefaultTitleTemplate); err != nil { logFail("tenant_settings invalid_default_title_template") writeError(w, http.StatusBadRequest, err.Error()) return } } var detail string if req.ScanTitleDateFormat != nil { if err := s.tenantStore.UpdateScanTitleDateFormat(r.Context(), tenantID, *req.ScanTitleDateFormat); err != nil { logFail("tenant_settings update_failed") writeError(w, http.StatusInternalServerError, "save tenant settings failed") return } detail += " scan_title_date_format:" + *req.ScanTitleDateFormat } if req.ScanTitlePrefix != nil { if err := s.tenantStore.UpdateScanTitlePrefix(r.Context(), tenantID, *req.ScanTitlePrefix); err != nil { // Store-level validation (length) or DB error. logFail("tenant_settings update_failed") writeError(w, http.StatusBadRequest, "invalid scan_title_prefix") return } detail += " scan_title_prefix:" + strings.TrimSpace(*req.ScanTitlePrefix) } if req.DefaultTitleTemplate != nil { if err := s.tenantStore.UpdateDefaultTitleTemplate(r.Context(), tenantID, *req.DefaultTitleTemplate); err != nil { logFail("tenant_settings update_failed") writeError(w, http.StatusBadRequest, "invalid default_title_template") return } detail += " default_title_template:" + strings.TrimSpace(*req.DefaultTitleTemplate) } tid := tenantID s.audlog.Log(audit.Entry{ EventType: audit.EventTenantMgmt, Username: sess.Username, TenantID: &tid, Success: true, Detail: "tenant_settings" + detail, }) // Reload so the response reflects the effective persisted state for both // fields, regardless of which one this request changed. t, err := s.tenantStore.GetByID(r.Context(), tenantID) if err != nil || t == nil { writeError(w, http.StatusInternalServerError, "load tenant settings failed") return } writeJSON(w, http.StatusOK, tenantSettingsResponse{ ScanTitleDateFormat: effectiveScanTitleFormat(t.ScanTitleDateFormat), ScanTitlePrefix: effectiveScanTitlePrefix(t.ScanTitlePrefix), DefaultTitleTemplate: t.DefaultTitleTemplate, AvailableFormats: availableScanTitleFormats(), }) }