Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
234 lines
8.5 KiB
Go
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(),
|
|
})
|
|
}
|