Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
416 lines
16 KiB
Go
416 lines
16 KiB
Go
// 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"`
|
|
}
|
|
|
|
// 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 <noreply@firma.de>"
|
|
}
|
|
|
|
// 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):
|
|
//
|
|
// <BasePath>/inbox/<tenant_id>/<uuid>.<ext> raw upload, pre-processing
|
|
// <BasePath>/store/<tenant_id>/<yyyy>/<mm>/<sha256>.<ext> finished archive (WORM, 0440)
|
|
// <BasePath>/ocr-tmp/<uuid>/ 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"`
|
|
}
|
|
|
|
// 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/<tenant_id>/<content_hash>.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 <deg>` — 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 "<storage.base_path>/.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
|
|
// "<basePath>/.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
|
|
}
|