// Package config loads the archivdms application configuration from a YAML // file. The structure mirrors the archivmail config pattern (Server/Database/ // API/SMTPOut/Audit sections) but drops everything mail-specific (IMAP/POP3/ // SMTP daemon, index backend, etc.) since archivdms is a document-centric DMS. package config import ( "fmt" "os" "path/filepath" "strings" "time" "gopkg.in/yaml.v3" ) // APIConfig holds configuration for the HTTP API server. type APIConfig struct { Bind string `yaml:"bind"` // Secret is the master secret from which the JWT signing key is derived. Secret string `yaml:"secret"` // SecureCookies sets the Secure flag on session cookies. Enable when TLS is // terminated at this server or at a trusted reverse proxy. SecureCookies bool `yaml:"secure_cookies"` // TrustedProxies is a list of IP addresses or CIDR ranges whose // X-Forwarded-For header is trusted. Empty = trust no proxy. TrustedProxies []string `yaml:"trusted_proxies"` // MetricsAllowedIPs sind zusätzliche Quell-IPs oder CIDR-Bereiche, die den // unauthentifizierten Prometheus-Endpunkt GET /metrics scrapen dürfen // (FDN-08). Loopback ist immer erlaubt, alles andere per Default gesperrt. MetricsAllowedIPs []string `yaml:"metrics_allowed_ips"` } // ServerConfig holds general server settings. type ServerConfig struct { FQDN string `yaml:"fqdn"` // used for generated links (invite/reset mails) APIPort int `yaml:"api_port"` } // DatabaseConfig holds PostgreSQL connection settings. type DatabaseConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` Name string `yaml:"name"` User string `yaml:"user"` Password string `yaml:"password"` SSLMode string `yaml:"sslmode"` } // DSN builds a PostgreSQL connection string from the config fields. func (d DatabaseConfig) DSN() string { return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", d.User, d.Password, d.Host, d.Port, d.Name, d.SSLMode) } // SMTPOutConfig holds settings for outgoing transactional email (invites, // password reset, reminder notifications). type SMTPOutConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` User string `yaml:"user"` Password string `yaml:"password"` TLS bool `yaml:"tls"` From string `yaml:"from"` // e.g. "archivdms " } // DefaultAuditLogPath is the default location of the append-only JSON-Lines // audit log file when audit.log_path is not configured. const DefaultAuditLogPath = "/var/log/archivdms/audit.log" // AuditConfig holds audit log settings. type AuditConfig struct { LogPath string `yaml:"log_path"` RetentionDays int `yaml:"retention_days"` } // ResolvedLogPath returns the configured audit log file path, falling back to // DefaultAuditLogPath when unset. func (a AuditConfig) ResolvedLogPath() string { if strings.TrimSpace(a.LogPath) == "" { return DefaultAuditLogPath } return a.LogPath } // LoggingConfig holds application logging settings. type LoggingConfig struct { Path string `yaml:"path"` Level string `yaml:"level"` } // StorageConfig holds settings for document blob storage on disk. // // Layout under BasePath (see internal/ocr and internal/api upload handler): // // /inbox//. raw upload, pre-processing // /store////. finished archive (WORM, 0440) // /ocr-tmp// pdftoppm scratch, removed after use type StorageConfig struct { // BasePath is the root directory for inbox/store/ocr-tmp (see helper // methods below). Replaces the old, unused StorePath/store_path field. BasePath string `yaml:"base_path"` // RetentionDays is the default GoBD retention period (days) applied to new // documents when no explicit retain_until is given. 0 = no default lock. RetentionDays int `yaml:"retention_days"` // MaxUploadSizeMB caps the accepted multipart upload size. 0 = default 50. MaxUploadSizeMB int `yaml:"max_upload_size_mb"` // SignedURLTTLMinutes is the default validity of signed download URLs // (internal/objectstore). 0 = default 15 minutes. SignedURLTTLMinutes int `yaml:"signed_url_ttl_minutes"` } // ResolvedSignedURLTTL returns the configured signed-download-URL validity, // falling back to 15 minutes when unset (<= 0). func (s StorageConfig) ResolvedSignedURLTTL() time.Duration { if s.SignedURLTTLMinutes <= 0 { return 15 * time.Minute } return time.Duration(s.SignedURLTTLMinutes) * time.Minute } // InboxPath returns the directory raw uploads are written to before hashing // and OCR processing. func (s StorageConfig) InboxPath() string { return filepath.Join(s.BasePath, "inbox") } // StorePath returns the directory the finished, content-addressed WORM // archive lives in. func (s StorageConfig) StorePath() string { return filepath.Join(s.BasePath, "store") } // OCRTmpPath returns the scratch directory for pdftoppm intermediate images. func (s StorageConfig) OCRTmpPath() string { return filepath.Join(s.BasePath, "ocr-tmp") } // ThumbnailPath returns the directory holding derived preview thumbnails. // Thumbnails are regenerable artefacts (not WORM): they may be deleted at any // time and are re-created lazily on next request. Layout mirrors the store: // thumbnails//.png. func (s StorageConfig) ThumbnailPath() string { return filepath.Join(s.BasePath, "thumbnails") } // ResolvedMaxUploadSizeMB returns MaxUploadSizeMB, falling back to a default // of 50 MB when unset (<= 0). func (s StorageConfig) ResolvedMaxUploadSizeMB() int { if s.MaxUploadSizeMB <= 0 { return 50 } return s.MaxUploadSizeMB } // OCRConfig holds settings for the tesseract/poppler-utils OCR sidecar // pipeline (internal/ocr). All binaries are optional system packages — a // missing binary degrades OCR to a no-op rather than failing the upload. type OCRConfig struct { // TesseractPath is the path/name of the tesseract binary. Default "tesseract". TesseractPath string `yaml:"tesseract_path"` // PdftoppmPath is the path/name of the pdftoppm binary. Default "pdftoppm". PdftoppmPath string `yaml:"pdftoppm_path"` // Languages is the tesseract -l argument, e.g. "deu+eng". Default "deu+eng". Languages string `yaml:"languages"` // TimeoutSeconds bounds each individual OCR subprocess call. Default 60. TimeoutSeconds int `yaml:"timeout_seconds"` // SofficePath is the path/name of the LibreOffice headless binary used to // convert Office documents (docx/xlsx/pptx/odt/...) to PDF before OCR. // Default "soffice". A missing binary degrades Office ingest to a no-op. SofficePath string `yaml:"soffice_path"` // BinarizeOCR enables an Otsu auto-threshold (black/white) step at the end // of the image preprocessing pipeline (internal/ocr.Extractor.Binarize), // after deskew/contrast-normalize/OSD-rotation. Default false: this is a // lossy step (every pixel becomes pure black or white) that helps flat // text scans but can hurt documents with color stamps/signatures or // embedded photos — enable per-deployment only after validating against // that corpus. Requires ImageMagick 7 (`-auto-threshold` syntax); a // missing/older convert binary just skips the step (best-effort, same as // every other preprocessing step here). BinarizeOCR bool `yaml:"binarize_ocr"` // DeskewMethod selects the fine-skew-angle correction strategy used in // internal/ocr.Extractor.runTesseract before the OSD 90-degree rotation // pass. "imagemagick" (default/empty) keeps the existing behavior: // ImageMagick's own `-deskew 40%` peak/valley background-projection // analysis (internal/ocr.deskewImage). "hough" instead detects the angle // via a Python/OpenCV sidecar script (internal/ocr/scripts/ // hough_deskew.py, minAreaRect/HoughLinesP-based) and applies it with a // plain `convert -rotate ` — separating angle detection from angle // application, which the ImageMagick approach does not do and which // fails on tightly-cropped phone photos lacking background margin. // Requires python3 + opencv-python (Debian: python3-opencv) on the host; // falls back to no-op (angle 0, same as any other best-effort // preprocessing step here) if the script/dependency is missing. DeskewMethod string `yaml:"deskew_method"` // HoughDeskewScriptPath overrides the path to hough_deskew.py. Empty // defaults to "/opt/archivdms/scripts/hough_deskew.py" (the on-premise // install layout — see install.sh/update.sh, INSTALL_DIR=/opt/archivdms). // Only consulted when DeskewMethod == "hough". HoughDeskewScriptPath string `yaml:"hough_deskew_script_path"` } // ResolvedTesseractPath returns TesseractPath, defaulting to "tesseract". func (o OCRConfig) ResolvedTesseractPath() string { if strings.TrimSpace(o.TesseractPath) == "" { return "tesseract" } return o.TesseractPath } // ResolvedPdftoppmPath returns PdftoppmPath, defaulting to "pdftoppm". func (o OCRConfig) ResolvedPdftoppmPath() string { if strings.TrimSpace(o.PdftoppmPath) == "" { return "pdftoppm" } return o.PdftoppmPath } // ResolvedSofficePath returns SofficePath, defaulting to "soffice". func (o OCRConfig) ResolvedSofficePath() string { if strings.TrimSpace(o.SofficePath) == "" { return "soffice" } return o.SofficePath } // ResolvedDeskewMethod returns DeskewMethod, defaulting to "imagemagick" // (the pre-existing behavior — see the DeskewMethod field doc). Any value // other than "hough" is treated as "imagemagick" so a typo in config.yaml // degrades to the known-safe default rather than silently disabling deskew. func (o OCRConfig) ResolvedDeskewMethod() string { if strings.TrimSpace(strings.ToLower(o.DeskewMethod)) == "hough" { return "hough" } return "imagemagick" } // ResolvedHoughDeskewScriptPath returns HoughDeskewScriptPath, defaulting to // "/opt/archivdms/scripts/hough_deskew.py" (see the field doc). func (o OCRConfig) ResolvedHoughDeskewScriptPath() string { if strings.TrimSpace(o.HoughDeskewScriptPath) == "" { return "/opt/archivdms/scripts/hough_deskew.py" } return o.HoughDeskewScriptPath } // ResolvedLanguages returns Languages, defaulting to "deu+eng". func (o OCRConfig) ResolvedLanguages() string { if strings.TrimSpace(o.Languages) == "" { return "deu+eng" } return o.Languages } // ResolvedTimeout returns TimeoutSeconds as a time.Duration, defaulting to 60s. func (o OCRConfig) ResolvedTimeout() time.Duration { if o.TimeoutSeconds <= 0 { return 60 * time.Second } return time.Duration(o.TimeoutSeconds) * time.Second } // SFTPConfig holds settings for the embedded per-tenant SFTP server // (internal/sftpserver). Disabled by default — no port is opened unless // explicitly enabled. type SFTPConfig struct { // Enabled turns the embedded SFTP server on/off. Default false. Enabled bool `yaml:"enabled"` // Bind is the listen address, e.g. ":2222". Bind string `yaml:"bind"` // HostKeyPath is where the server's SSH host key is persisted. Generated // on first start if missing. Defaults to "/.ssh/host_key". HostKeyPath string `yaml:"host_key_path"` } // ResolvedBind returns Bind, defaulting to ":2222". func (c SFTPConfig) ResolvedBind() string { if strings.TrimSpace(c.Bind) == "" { return ":2222" } return c.Bind } // ResolvedHostKeyPath returns HostKeyPath, defaulting to // "/.ssh/host_key" when unset. func (c SFTPConfig) ResolvedHostKeyPath(basePath string) string { if strings.TrimSpace(c.HostKeyPath) != "" { return c.HostKeyPath } return filepath.Join(basePath, ".ssh", "host_key") } // IndexConfig holds settings for the optional full-text search index // (internal/index, Manticore Search over the MySQL protocol, port 9306). // // Phase 1 wires only the write/sync layer — there is no search endpoint yet. // When ManticoreDSN is empty the index is disabled entirely: the store's // Indexer stays nil and every sync call is a silent no-op (Postgres remains the // single source of truth). type IndexConfig struct { // ManticoreDSN is a go-sql-driver/mysql DSN pointing at Manticore's SQL // port, e.g. "archivdms@tcp(127.0.0.1:9306)/?charset=utf8mb4". Empty = // index disabled. ManticoreDSN string `yaml:"manticore_dsn"` } // JobQueueConfig holds settings for the tenant-fair, Postgres-backed // processing queue (internal/jobqueue): OCR extraction, taxonomy // auto-assignment and on_upload workflows run asynchronously in worker // goroutines inside this same process (no separate service/container). // // All fields are optional — the defaults below are tuned for a single // mid-sized server running Tesseract locally. type JobQueueConfig struct { // Disabled turns the async pipeline off entirely. Documents then stay in // processing_status='queued' until the queue is enabled again (nothing is // lost — the WORM file and the job row are already persisted). Default // false, i.e. the queue runs. Disabled bool `yaml:"disabled"` // Workers is the number of concurrent worker goroutines. Default 2 — // Tesseract is CPU-bound, more workers than cores hurts. Workers int `yaml:"workers"` // PollIntervalMS is how often the dispatcher looks for due jobs. // Default 2000 (2s). PollIntervalMS int `yaml:"poll_interval_ms"` // JobTimeoutSeconds bounds one job run and doubles as the reaper's // threshold for stuck 'processing' rows. Default 600 (10 min) — large // multi-page scans plus LibreOffice conversion can legitimately take // minutes. JobTimeoutSeconds int `yaml:"job_timeout_seconds"` // MaxRetries is the retry_count cap. Once exceeded, the job stays // permanently 'failed' and is only retried on explicit manual request. // Default 5. MaxRetries int `yaml:"max_retries"` } // ResolvedWorkers returns Workers, defaulting to 2. func (j JobQueueConfig) ResolvedWorkers() int { if j.Workers <= 0 { return 2 } return j.Workers } // ResolvedPollInterval returns PollIntervalMS as a Duration, default 2s. func (j JobQueueConfig) ResolvedPollInterval() time.Duration { if j.PollIntervalMS <= 0 { return 2 * time.Second } return time.Duration(j.PollIntervalMS) * time.Millisecond } // ResolvedJobTimeout returns JobTimeoutSeconds as a Duration, default 10min. func (j JobQueueConfig) ResolvedJobTimeout() time.Duration { if j.JobTimeoutSeconds <= 0 { return 10 * time.Minute } return time.Duration(j.JobTimeoutSeconds) * time.Second } // ResolvedMaxRetries returns MaxRetries, defaulting to 5. func (j JobQueueConfig) ResolvedMaxRetries() int { if j.MaxRetries <= 0 { return 5 } return j.MaxRetries } // PageSplitConfig holds settings for barcode separator-page splitting at // ingest (internal/pagesplit). A printed separator sheet carrying the // configured barcode cuts a multi-page PDF scan into several individual // documents; the separator page itself is discarded. // // Deliberately disabled by default and configured globally (config.yaml) // rather than per tenant for now: this is the first iteration, and the // project rule for new preprocessing behaviour is to introduce it // conservatively and validate it in the field before turning it on broadly // (same approach as ocr.binarize_ocr). A per-tenant setting plus a settings UI // is the intended next step. type PageSplitConfig struct { // Enabled turns separator-page splitting on. Default false. Enabled bool `yaml:"enabled"` // Marker is the barcode payload identifying a separator page. Empty // defaults to pagesplit.DefaultMarker ("ARCHIVDMS-SPLIT"). Matched // case-insensitively after trimming. Marker string `yaml:"marker"` // MarkerPrefix switches matching from equality to prefix matching, so a // separator sheet may carry additional payload after the marker. MarkerPrefix bool `yaml:"marker_prefix"` // RasterDPI is the resolution separator detection rasterizes pages at. // 0 = default 150 (enough for a full-page separator barcode, far cheaper // than the 300 dpi OCR pass). RasterDPI int `yaml:"raster_dpi"` // MaxPages caps how many pages are analysed; longer documents are archived // unsplit. 0 = default 200. MaxPages int `yaml:"max_pages"` // TimeoutSeconds bounds each poppler subprocess call. 0 = default 120. TimeoutSeconds int `yaml:"timeout_seconds"` } // Config is the full application configuration loaded from YAML. type Config struct { Server ServerConfig `yaml:"server"` Storage StorageConfig `yaml:"storage"` OCR OCRConfig `yaml:"ocr"` PageSplit PageSplitConfig `yaml:"pagesplit"` JobQueue JobQueueConfig `yaml:"jobqueue"` SFTP SFTPConfig `yaml:"sftp"` Index IndexConfig `yaml:"index"` Database DatabaseConfig `yaml:"database"` SMTPOut SMTPOutConfig `yaml:"smtp_out"` API APIConfig `yaml:"api"` Audit AuditConfig `yaml:"audit"` Logging LoggingConfig `yaml:"logging"` } // Load reads a YAML config file from path and returns a parsed Config. // It also bootstraps the storage directory tree (inbox/store/ocr-tmp) so the // upload handler and OCR pipeline can rely on it existing at startup. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, err } if cfg.Storage.BasePath != "" { for _, dir := range []string{cfg.Storage.InboxPath(), cfg.Storage.StorePath(), cfg.Storage.OCRTmpPath(), cfg.Storage.ThumbnailPath()} { if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("config: create storage dir %s: %w", dir, err) } } } return &cfg, nil }