Files
archivdms/internal/api/tenant_settings_handlers.go
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

234 lines
8.5 KiB
Go

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(),
})
}