d34b8bceb9
Remove duplicate SETTING_METADATA entries (db_pool_size, db_max_overflow, db_pool_timeout, db_pool_recycle, upload_rate_limit_per_user, upload_rate_limit_window) that were introduced when merging origin/main. Also remove duplicate Settings class field definitions in config.py. Fixes ruff F601 (repeated dictionary key literal) errors. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
1460 lines
64 KiB
Python
1460 lines
64 KiB
Python
#!/usr/bin/env python3
|
||
|
||
import json
|
||
import os
|
||
from typing import Any, List, Optional, Union
|
||
|
||
from pydantic import Field, field_validator, model_validator
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(env_file=".env")
|
||
|
||
database_url: str
|
||
redis_url: str
|
||
|
||
# Database connection-pool tuning (ignored for SQLite, which uses NullPool).
|
||
db_pool_size: int = Field(
|
||
default=10,
|
||
description="Number of persistent connections kept in the pool per worker process.",
|
||
)
|
||
db_max_overflow: int = Field(
|
||
default=20,
|
||
description="Additional connections allowed beyond db_pool_size under burst load.",
|
||
)
|
||
db_pool_timeout: int = Field(
|
||
default=30,
|
||
description="Seconds to wait for a connection from the pool before raising a TimeoutError.",
|
||
)
|
||
db_pool_recycle: int = Field(
|
||
default=1800,
|
||
description="Recycle (close and reopen) connections after this many seconds to avoid stale connections.",
|
||
)
|
||
openai_api_key: str
|
||
openai_base_url: str = "https://api.openai.com/v1" # Default to OpenAI's endpoint
|
||
openai_model: str = "gpt-4o-mini" # Default model
|
||
|
||
# AI provider abstraction layer
|
||
# Supported values: openai, azure, anthropic, gemini, ollama, openrouter, litellm
|
||
ai_provider: str = "openai"
|
||
# Override model for any provider; falls back to openai_model when not set
|
||
ai_model: Optional[str] = None
|
||
|
||
# Anthropic Claude settings (used when ai_provider="anthropic")
|
||
anthropic_api_key: Optional[str] = None
|
||
|
||
# Google Gemini settings (used when ai_provider="gemini")
|
||
gemini_api_key: Optional[str] = None
|
||
|
||
# Ollama local LLM settings (used when ai_provider="ollama")
|
||
ollama_base_url: str = "http://localhost:11434"
|
||
|
||
# OpenRouter settings (used when ai_provider="openrouter")
|
||
openrouter_api_key: Optional[str] = None
|
||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||
|
||
# Portkey AI gateway settings (used when ai_provider="portkey")
|
||
# See https://portkey.ai for setup instructions
|
||
portkey_api_key: Optional[str] = None
|
||
portkey_virtual_key: Optional[str] = None # Routes to a specific provider via Portkey vault
|
||
portkey_config: Optional[str] = None # Portkey Config ID for advanced routing rules
|
||
portkey_base_url: str = "https://api.portkey.ai/v1"
|
||
|
||
# Azure OpenAI API version (used when ai_provider="azure")
|
||
azure_openai_api_version: str = "2024-02-01"
|
||
workdir: str
|
||
debug: bool = False # Default to False
|
||
|
||
# Logging level for the application. Accepts standard Python level names:
|
||
# DEBUG, INFO, WARNING, ERROR, CRITICAL. When *debug* is True and
|
||
# *log_level* has not been explicitly set, the effective level is forced to
|
||
# DEBUG so that all ``logger.debug()`` calls produce output.
|
||
log_level: str = Field(
|
||
default="INFO",
|
||
description=(
|
||
"Python logging level for the application root logger. "
|
||
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
|
||
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
|
||
"the effective level is automatically lowered to DEBUG."
|
||
),
|
||
)
|
||
|
||
# Log output format. ``text`` is the human-readable default.
|
||
# ``json`` emits one JSON object per line, ideal for log collectors
|
||
# (Promtail, Fluentd, Filebeat, Datadog agent) and SIEM ingestion.
|
||
log_format: str = Field(
|
||
default="text",
|
||
description=(
|
||
"Log output format: 'text' (human-readable, default) or "
|
||
"'json' (structured JSON lines for SIEM / log aggregation)."
|
||
),
|
||
)
|
||
|
||
# Optional syslog forwarding for application logs (not just audit events).
|
||
# When enabled, a Python SysLogHandler is added to the root logger so that
|
||
# every log message is also sent to the configured syslog receiver.
|
||
log_syslog_enabled: bool = Field(
|
||
default=False,
|
||
description="Forward application logs to a syslog receiver in addition to stdout.",
|
||
)
|
||
log_syslog_host: str = Field(
|
||
default="localhost",
|
||
description="Hostname or IP of the syslog receiver for application logs.",
|
||
)
|
||
log_syslog_port: int = Field(
|
||
default=514,
|
||
description="Port of the syslog receiver for application logs.",
|
||
)
|
||
log_syslog_protocol: str = Field(
|
||
default="udp",
|
||
description="Protocol for syslog transport: 'udp' or 'tcp'.",
|
||
)
|
||
|
||
# Making Dropbox optional
|
||
dropbox_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable Dropbox as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
dropbox_app_key: Optional[str] = None
|
||
dropbox_app_secret: Optional[str] = None
|
||
dropbox_folder: Optional[str] = None
|
||
dropbox_refresh_token: Optional[str] = None
|
||
|
||
# Making Nextcloud optional
|
||
nextcloud_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable Nextcloud as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
nextcloud_upload_url: Optional[str] = None
|
||
nextcloud_username: Optional[str] = None
|
||
nextcloud_password: Optional[str] = None
|
||
nextcloud_folder: Optional[str] = None
|
||
|
||
# Making Paperless optional
|
||
paperless_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable Paperless-ngx as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
paperless_ngx_api_token: Optional[str] = None
|
||
paperless_host: Optional[str] = None
|
||
paperless_custom_field_absender: Optional[str] = None # Name of the "absender" custom field in Paperless
|
||
# JSON mapping of metadata field names to Paperless custom field names
|
||
# Example: {"absender": "Sender", "empfaenger": "Recipient",
|
||
# "language": "Language", "correspondent": "Correspondent"}
|
||
paperless_custom_fields_mapping: Optional[str] = None
|
||
|
||
azure_ai_key: str
|
||
azure_region: str
|
||
azure_endpoint: str
|
||
gotenberg_url: str
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OCR provider settings
|
||
# ---------------------------------------------------------------------------
|
||
# Comma-separated list of OCR engines to use.
|
||
# Supported values: azure, tesseract, easyocr, mistral, google_docai, aws_textract
|
||
# When multiple engines are listed all are run and results are merged.
|
||
# Example: OCR_PROVIDERS=azure,tesseract
|
||
ocr_providers: str = "azure"
|
||
|
||
# Strategy for merging results from multiple OCR providers.
|
||
# - ai_merge : Ask the AI model to produce the best merged text (default).
|
||
# - longest : Return the result with the most characters.
|
||
# - primary : Return only the first provider's result (no merging).
|
||
ocr_merge_strategy: str = "ai_merge"
|
||
|
||
# Tesseract OCR settings (used when "tesseract" is in OCR_PROVIDERS)
|
||
tesseract_cmd: Optional[str] = None # Path to tesseract binary (e.g. /usr/bin/tesseract)
|
||
tesseract_language: str = "eng+deu" # Tesseract language code(s), e.g. "eng" or "eng+deu"
|
||
|
||
# EasyOCR settings (used when "easyocr" is in OCR_PROVIDERS)
|
||
easyocr_languages: str = "en,de" # Comma-separated language codes, e.g. "en,de,fr"
|
||
easyocr_gpu: bool = False # Enable GPU acceleration for EasyOCR
|
||
|
||
# Mistral OCR settings (used when "mistral" is in OCR_PROVIDERS)
|
||
mistral_api_key: Optional[str] = None
|
||
mistral_ocr_model: str = "mistral-ocr-latest"
|
||
|
||
# Google Cloud Document AI settings (used when "google_docai" is in OCR_PROVIDERS)
|
||
# Falls back to google_drive_credentials_json for service account credentials.
|
||
google_docai_credentials_json: Optional[str] = None
|
||
google_docai_project_id: Optional[str] = None
|
||
google_docai_processor_id: Optional[str] = None
|
||
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
||
external_hostname: str = "localhost" # Default to localhost
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Document Translation Settings
|
||
# ---------------------------------------------------------------------------
|
||
# Default target language for automatic document translation (ISO 639-1 code).
|
||
# After OCR / metadata extraction, if the detected document language differs
|
||
# from this value the system translates the extracted text into this language
|
||
# and stores it alongside the original. Other language translations are
|
||
# generated on the fly via the AI provider and are NOT persisted.
|
||
# Per-user overrides are stored in UserProfile.default_document_language.
|
||
default_document_language: str = Field(
|
||
default="en",
|
||
description=(
|
||
"ISO 639-1 language code for the default translation target "
|
||
"(e.g. 'en', 'de', 'fr'). Documents whose detected language "
|
||
"differs are automatically translated into this language after "
|
||
"processing. Default: 'en' (English)."
|
||
),
|
||
)
|
||
|
||
# Authentication settings
|
||
auth_enabled: bool = True # Default to enabled
|
||
admin_username: Optional[str] = None
|
||
admin_password: Optional[str] = None
|
||
session_secret: Optional[str] = None
|
||
session_lifetime_days: int = Field(
|
||
default=30,
|
||
description=(
|
||
"Session lifetime in days. Common values: 30, 60, 90. "
|
||
"Determines how long a user stays logged in before being required to re-authenticate. "
|
||
"Applies to both browser sessions and the session cookie max_age."
|
||
),
|
||
)
|
||
session_lifetime_custom_days: int | None = Field(
|
||
default=None,
|
||
description=(
|
||
"Override session_lifetime_days with a custom value. "
|
||
"When set, this takes precedence over session_lifetime_days. "
|
||
"Useful for admin-configured non-standard durations."
|
||
),
|
||
)
|
||
qr_login_challenge_ttl_seconds: int = Field(
|
||
default=120,
|
||
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
||
)
|
||
admin_group_name: str = "admin"
|
||
|
||
# Multi-user settings
|
||
multi_user_enabled: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable multi-user mode with individual document spaces per user. "
|
||
"When enabled, each authenticated user sees only their own documents, "
|
||
"uploads, and search results. Shared settings (AI, OCR) remain global. "
|
||
"Requires auth_enabled=True. Default: False (single-user/shared mode)."
|
||
),
|
||
)
|
||
default_daily_upload_limit: int = Field(
|
||
default=0,
|
||
description=(
|
||
"Default maximum number of document uploads allowed per user per day "
|
||
"in multi-user mode. Set to 0 for unlimited. "
|
||
"Individual user limits can override this default. Default: 0 (unlimited)."
|
||
),
|
||
)
|
||
unowned_docs_visible_to_all: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"In multi-user mode, controls whether documents without an owner (owner_id is NULL) "
|
||
"are visible to all authenticated users. When True, unowned documents appear in every "
|
||
"user's file list alongside their own files. When False, only admins can see unowned "
|
||
"documents. Default: True."
|
||
),
|
||
)
|
||
default_owner_id: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"When set, automatically assigns this owner ID to newly ingested documents that would "
|
||
"otherwise have no owner (e.g. documents from IMAP, API without session, or legacy imports). "
|
||
"Use the admin /api/files/assign-owner endpoint to bulk-assign existing unclaimed documents. "
|
||
"Default: None (documents remain unowned until claimed)."
|
||
),
|
||
)
|
||
|
||
subscription_overage_percent: int = Field(
|
||
default=20,
|
||
ge=0,
|
||
le=200,
|
||
description=(
|
||
"Soft-limit overage buffer in percent (0–200). The announced monthly quota is "
|
||
"increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month "
|
||
"plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced "
|
||
"limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. "
|
||
"Default: 20."
|
||
),
|
||
)
|
||
|
||
# Authentik / Generic OIDC
|
||
authentik_client_id: Optional[str] = None
|
||
authentik_client_secret: Optional[str] = None
|
||
authentik_config_url: Optional[str] = None
|
||
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
|
||
|
||
# Social Login Providers
|
||
# Google OAuth2
|
||
social_auth_google_enabled: bool = False
|
||
social_auth_google_client_id: Optional[str] = None
|
||
social_auth_google_client_secret: Optional[str] = None
|
||
|
||
# Microsoft OAuth2 (Azure AD / Microsoft Entra ID)
|
||
social_auth_microsoft_enabled: bool = False
|
||
social_auth_microsoft_client_id: Optional[str] = None
|
||
social_auth_microsoft_client_secret: Optional[str] = None
|
||
social_auth_microsoft_tenant: str = Field(
|
||
default="common",
|
||
description=(
|
||
"Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. "
|
||
"Use 'common' to allow any Microsoft account and any Azure AD org. "
|
||
"Use a specific tenant ID (GUID) to restrict to a single organization. "
|
||
"Default: common."
|
||
),
|
||
)
|
||
|
||
# Apple Sign-In
|
||
social_auth_apple_enabled: bool = False
|
||
social_auth_apple_client_id: Optional[str] = None
|
||
social_auth_apple_team_id: Optional[str] = None
|
||
social_auth_apple_key_id: Optional[str] = None
|
||
social_auth_apple_private_key: Optional[str] = None
|
||
|
||
# Dropbox OAuth2
|
||
social_auth_dropbox_enabled: bool = False
|
||
social_auth_dropbox_client_id: Optional[str] = None
|
||
social_auth_dropbox_client_secret: Optional[str] = None
|
||
|
||
# Local user signup
|
||
allow_local_signup: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Allow users to self-register with email and password. "
|
||
"Has no effect unless MULTI_USER_ENABLED is also True. "
|
||
"Requires SMTP to be configured so verification emails can be sent. "
|
||
"Default: False (registration disabled — admin creates users manually)."
|
||
),
|
||
)
|
||
|
||
# Stripe billing
|
||
stripe_secret_key: Optional[str] = None
|
||
stripe_publishable_key: Optional[str] = None
|
||
stripe_webhook_secret: Optional[str] = None
|
||
stripe_success_url: Optional[str] = None # e.g. https://app.example.com/billing/success
|
||
stripe_cancel_url: Optional[str] = None # e.g. https://app.example.com/pricing
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Watch Folder Ingestion
|
||
# ---------------------------------------------------------------------------
|
||
# Local filesystem watch folders (comma-separated list of absolute paths).
|
||
# DocuElevate will poll each path for new files and automatically ingest them.
|
||
# Works with any mounted path, including SMB/CIFS (via system mount), NFS, etc.
|
||
# Example: /watchfolders/scanner,/mnt/shared/inbox
|
||
watch_folders: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Comma-separated list of local filesystem paths (absolute) that DocuElevate will "
|
||
"poll for new files to ingest. Each file found is enqueued for document processing. "
|
||
"Works with any mounted path including SMB/CIFS (mounted via system) and NFS. "
|
||
"Example: /watchfolders/scanner,/mnt/shared/inbox"
|
||
),
|
||
)
|
||
watch_folder_poll_interval: int = Field(
|
||
default=1,
|
||
description=("Poll interval in minutes for local watch folder scanning. Default: 1 minute."),
|
||
)
|
||
watch_folder_delete_after_process: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Delete files from local watch folders after they have been successfully enqueued "
|
||
"for processing. When False (default), files are left in place and tracked via a "
|
||
"cache file to avoid re-ingesting them."
|
||
),
|
||
)
|
||
|
||
# FTP Ingest / Watch Folder
|
||
# Uses the existing FTP credentials (ftp_host, ftp_username, ftp_password) to poll
|
||
# a source folder on the FTP server for new files to ingest.
|
||
ftp_ingest_folder: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"FTP folder path to monitor for new files to ingest. "
|
||
"Uses the existing FTP connection settings (FTP_HOST, FTP_USERNAME, FTP_PASSWORD). "
|
||
"When set, DocuElevate will periodically poll this folder and download new files for processing."
|
||
),
|
||
)
|
||
ftp_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable FTP watch folder ingestion. Requires FTP_INGEST_FOLDER and FTP connection settings.",
|
||
)
|
||
ftp_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Delete files from the FTP ingest folder after they have been successfully downloaded "
|
||
"and enqueued for processing. Default: False (files are left in place)."
|
||
),
|
||
)
|
||
|
||
# SFTP Ingest / Watch Folder
|
||
# Uses the existing SFTP credentials (sftp_host, sftp_username, sftp_password/sftp_private_key)
|
||
# to poll a source folder on the SFTP server for new files to ingest.
|
||
sftp_ingest_folder: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"SFTP folder path to monitor for new files to ingest. "
|
||
"Uses the existing SFTP connection settings (SFTP_HOST, SFTP_USERNAME, SFTP_PASSWORD/SFTP_PRIVATE_KEY). "
|
||
"When set, DocuElevate will periodically poll this folder and download new files for processing."
|
||
),
|
||
)
|
||
sftp_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable SFTP watch folder ingestion. Requires SFTP_INGEST_FOLDER and SFTP connection settings.",
|
||
)
|
||
sftp_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Delete files from the SFTP ingest folder after they have been successfully downloaded "
|
||
"and enqueued for processing. Default: False (files are left in place)."
|
||
),
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cloud Provider Watch Folders
|
||
# ---------------------------------------------------------------------------
|
||
# Each cloud provider has three settings:
|
||
# <provider>_ingest_enabled — enable the watch-folder for this provider
|
||
# <provider>_ingest_folder — the remote path / folder ID to poll
|
||
# <provider>_ingest_delete_after_process — delete from cloud after download
|
||
|
||
# Dropbox ingest — reuses existing Dropbox OAuth credentials
|
||
dropbox_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable Dropbox watch folder ingestion. Requires Dropbox OAuth credentials.",
|
||
)
|
||
dropbox_ingest_folder: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Dropbox folder path to poll for new files to ingest (e.g. /Inbox/Scanner). "
|
||
"Uses the existing Dropbox OAuth credentials."
|
||
),
|
||
)
|
||
dropbox_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description="Delete files from Dropbox ingest folder after download and enqueue.",
|
||
)
|
||
|
||
# Google Drive ingest — reuses existing Google Drive credentials
|
||
google_drive_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable Google Drive watch folder ingestion. Requires Google Drive credentials.",
|
||
)
|
||
google_drive_ingest_folder_id: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Google Drive folder ID to poll for new files to ingest. "
|
||
"Uses the existing Google Drive service-account or OAuth credentials."
|
||
),
|
||
)
|
||
google_drive_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description="Delete files from Google Drive ingest folder after download and enqueue.",
|
||
)
|
||
|
||
# OneDrive ingest — reuses existing OneDrive MSAL credentials
|
||
onedrive_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable OneDrive watch folder ingestion. Requires OneDrive MSAL credentials.",
|
||
)
|
||
onedrive_ingest_folder_path: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"OneDrive folder path to poll for new files to ingest (e.g. /Inbox/Scanner). "
|
||
"Uses the existing OneDrive client credentials."
|
||
),
|
||
)
|
||
onedrive_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description="Delete files from OneDrive ingest folder after download and enqueue.",
|
||
)
|
||
|
||
# Nextcloud ingest — reuses existing Nextcloud WebDAV credentials
|
||
nextcloud_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable Nextcloud watch folder ingestion. Requires Nextcloud WebDAV credentials.",
|
||
)
|
||
nextcloud_ingest_folder: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Nextcloud folder path to poll for new files to ingest (e.g. /Scans/Inbox). "
|
||
"Uses the existing Nextcloud upload URL and credentials."
|
||
),
|
||
)
|
||
nextcloud_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description="Delete files from Nextcloud ingest folder after download and enqueue.",
|
||
)
|
||
|
||
# S3 ingest — reuses existing AWS/S3 credentials
|
||
s3_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable Amazon S3 watch folder (prefix) ingestion. Requires S3 credentials.",
|
||
)
|
||
s3_ingest_prefix: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"S3 key prefix to poll for new objects to ingest (e.g. inbox/scanner/). "
|
||
"Uses the existing S3 bucket and AWS credentials."
|
||
),
|
||
)
|
||
s3_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description="Delete objects from S3 ingest prefix after download and enqueue.",
|
||
)
|
||
|
||
# WebDAV ingest — reuses existing WebDAV credentials
|
||
webdav_ingest_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable WebDAV watch folder ingestion. Requires WebDAV URL and credentials.",
|
||
)
|
||
webdav_ingest_folder: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"WebDAV folder path to poll for new files to ingest (e.g. /remote.php/webdav/Inbox). "
|
||
"Uses the existing WebDAV URL and credentials."
|
||
),
|
||
)
|
||
webdav_ingest_delete_after_process: bool = Field(
|
||
default=False,
|
||
description="Delete files from WebDAV ingest folder after download and enqueue.",
|
||
)
|
||
|
||
# IMAP 1
|
||
imap1_host: Optional[str] = None
|
||
imap1_port: Optional[int] = 993
|
||
imap1_username: Optional[str] = None
|
||
imap1_password: Optional[str] = None
|
||
imap1_ssl: bool = True
|
||
imap1_poll_interval_minutes: int = 5
|
||
imap1_delete_after_process: bool = False
|
||
|
||
# IMAP 2
|
||
imap2_host: Optional[str] = None
|
||
imap2_port: Optional[int] = 993
|
||
imap2_username: Optional[str] = None
|
||
imap2_password: Optional[str] = None
|
||
imap2_ssl: bool = True
|
||
imap2_poll_interval_minutes: int = 10
|
||
imap2_delete_after_process: bool = False
|
||
|
||
# Google Drive settings
|
||
google_drive_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable Google Drive as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
google_drive_credentials_json: Optional[str] = ""
|
||
google_drive_folder_id: Optional[str] = ""
|
||
google_drive_delegate_to: Optional[str] = "" # Optional delegated user email
|
||
|
||
# Google Drive OAuth settings
|
||
google_drive_use_oauth: bool = False # Default to service account method
|
||
google_drive_client_id: Optional[str] = ""
|
||
google_drive_client_secret: Optional[str] = ""
|
||
google_drive_refresh_token: Optional[str] = ""
|
||
|
||
# WebDAV settings
|
||
webdav_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable WebDAV as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
webdav_url: Optional[str] = None
|
||
webdav_username: Optional[str] = None
|
||
webdav_password: Optional[str] = None
|
||
webdav_folder: Optional[str] = None
|
||
webdav_verify_ssl: bool = True
|
||
|
||
# FTP settings
|
||
ftp_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable FTP as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
ftp_host: Optional[str] = None
|
||
ftp_port: Optional[int] = 21
|
||
ftp_username: Optional[str] = None
|
||
ftp_password: Optional[str] = None
|
||
ftp_folder: Optional[str] = None
|
||
ftp_use_tls: bool = True # Default to attempting TLS connection first
|
||
ftp_allow_plaintext: bool = True # Default to allowing plaintext fallback
|
||
|
||
# SFTP settings
|
||
sftp_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable SFTP as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
sftp_host: Optional[str] = None
|
||
sftp_port: Optional[int] = 22
|
||
sftp_username: Optional[str] = None
|
||
sftp_password: Optional[str] = None
|
||
sftp_folder: Optional[str] = None
|
||
sftp_private_key: Optional[str] = None
|
||
sftp_private_key_passphrase: Optional[str] = None
|
||
# Security: Host key verification is enabled by default for security
|
||
# In development/testing, set to True to disable verification (not recommended)
|
||
sftp_disable_host_key_verification: bool = False # Default enforces host key verification
|
||
|
||
# Email settings (shared SMTP – used for password reset, verification emails, etc.)
|
||
email_host: Optional[str] = None
|
||
email_port: Optional[int] = 587
|
||
email_username: Optional[str] = None
|
||
email_password: Optional[str] = None
|
||
email_use_tls: bool = True
|
||
email_sender: Optional[str] = None # From address, defaults to email_username if not set
|
||
email_default_recipient: Optional[str] = None
|
||
|
||
# Email destination settings (dedicated SMTP for document delivery – decoupled from shared email above)
|
||
dest_email_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable Email as an upload destination. Set to False to disable document delivery via email even when credentials are configured.",
|
||
)
|
||
dest_email_host: Optional[str] = None
|
||
dest_email_port: Optional[int] = 587
|
||
dest_email_username: Optional[str] = None
|
||
dest_email_password: Optional[str] = None
|
||
dest_email_use_tls: bool = True
|
||
dest_email_sender: Optional[str] = None # From address for delivered documents
|
||
dest_email_default_recipient: Optional[str] = None # Fallback recipient for document delivery
|
||
|
||
# OneDrive settings
|
||
onedrive_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable OneDrive as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
onedrive_client_id: Optional[str] = None
|
||
onedrive_client_secret: Optional[str] = None
|
||
onedrive_tenant_id: Optional[str] = "common" # Default to "common" for personal accounts
|
||
onedrive_refresh_token: Optional[str] = None # Required for personal accounts
|
||
onedrive_folder_path: Optional[str] = None
|
||
|
||
# SharePoint settings
|
||
sharepoint_client_id: Optional[str] = None
|
||
sharepoint_client_secret: Optional[str] = None
|
||
sharepoint_tenant_id: Optional[str] = "common"
|
||
sharepoint_refresh_token: Optional[str] = None
|
||
sharepoint_site_url: Optional[str] = None # e.g. https://tenant.sharepoint.com/sites/sitename
|
||
sharepoint_document_library: Optional[str] = "Documents" # Document library name
|
||
sharepoint_folder_path: Optional[str] = None # Subfolder inside the library
|
||
|
||
# AWS S3 settings
|
||
s3_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable Amazon S3 as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
aws_access_key_id: Optional[str] = None
|
||
aws_secret_access_key: Optional[str] = None
|
||
aws_region: Optional[str] = "us-east-1" # Default region
|
||
s3_bucket_name: Optional[str] = None
|
||
s3_folder_prefix: Optional[str] = "" # Optional folder prefix (e.g. "uploads/")
|
||
s3_storage_class: Optional[str] = "STANDARD" # Default storage class
|
||
s3_acl: Optional[str] = "private" # Default ACL
|
||
|
||
# iCloud Drive settings
|
||
icloud_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable iCloud Drive as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
||
)
|
||
icloud_username: Optional[str] = None # Apple ID email address
|
||
icloud_password: Optional[str] = None # App-specific password (required for 2FA accounts)
|
||
icloud_folder: Optional[str] = None # Target folder path in iCloud Drive (e.g. "Documents/Uploads")
|
||
icloud_cookie_directory: Optional[str] = None # Directory for session cookies (default: ~/.pyicloud)
|
||
|
||
# Uptime Kuma settings
|
||
uptime_kuma_url: Optional[str] = None
|
||
uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes
|
||
|
||
# Meilisearch settings (full-text search engine)
|
||
# Default uses the Docker Compose / K8s service name so container-to-container
|
||
# networking works without any extra configuration. Override to
|
||
# "http://localhost:7700" only when running the API process outside of Docker.
|
||
meilisearch_url: str = "http://meilisearch:7700"
|
||
meilisearch_api_key: Optional[str] = None # Master or API key (optional for local dev)
|
||
meilisearch_index_name: str = "documents"
|
||
enable_search: bool = True # Enable Meilisearch full-text search integration
|
||
|
||
# HTTP request settings
|
||
http_request_timeout: int = 120 # Default timeout for HTTP requests in seconds (handles large file operations)
|
||
|
||
# Feature flags
|
||
allow_file_delete: bool = True # Default to allowing file deletion from database
|
||
compliance_enabled: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). "
|
||
"When enabled, admins can view compliance status and apply "
|
||
"pre-built regulatory configurations. Default: True."
|
||
),
|
||
)
|
||
|
||
# System reset / factory reset settings
|
||
factory_reset_on_startup: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"When enabled, DocuElevate wipes all user data (database rows and "
|
||
"work-files on disk) on every startup so the instance always comes "
|
||
"up in a clean, fresh state. Useful for demo or testing environments. "
|
||
"Default: False."
|
||
),
|
||
)
|
||
enable_factory_reset: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Show the 'System Reset' page in the admin UI. When enabled, "
|
||
"administrators can trigger a full data wipe or a wipe-and-reimport "
|
||
"directly from the web interface. Default: False."
|
||
),
|
||
)
|
||
|
||
# PDF/A archival conversion settings
|
||
enable_pdfa_conversion: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable PDF/A archival variant generation. When enabled, PDF/A copies of both the "
|
||
"original ingested file and the processed file are created and saved alongside the "
|
||
"standard copies. Uses ocrmypdf with Ghostscript for the conversion. "
|
||
"This may double or triple storage but provides better legal coverage. Default: False."
|
||
),
|
||
)
|
||
pdfa_format: str = Field(
|
||
default="2",
|
||
description=(
|
||
"PDF/A format variant to produce. Passed to ocrmypdf --output-type pdfa-N. "
|
||
"Valid values: '1' (PDF/A-1b), '2' (PDF/A-2b), '3' (PDF/A-3b). Default: '2'."
|
||
),
|
||
)
|
||
pdfa_upload_original: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Upload the original-file PDF/A variant to all configured storage providers. "
|
||
"Files are placed in the provider's folder + PDFA_UPLOAD_FOLDER subfolder. Default: False."
|
||
),
|
||
)
|
||
pdfa_upload_processed: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Upload the processed-file PDF/A variant to all configured storage providers. "
|
||
"Files are placed in the provider's folder + PDFA_UPLOAD_FOLDER subfolder. Default: False."
|
||
),
|
||
)
|
||
pdfa_upload_folder: str = Field(
|
||
default="pdfa",
|
||
description=(
|
||
"Subfolder name appended to each storage provider's configured folder for PDF/A uploads. "
|
||
"For example if Dropbox folder is '/Documents' and this is 'pdfa', PDF/A files go to "
|
||
"'/Documents/pdfa'. Set to empty string to upload into the same folder. Default: 'pdfa'."
|
||
),
|
||
)
|
||
google_drive_pdfa_folder_id: str = Field(
|
||
default="",
|
||
description=(
|
||
"Google Drive folder ID for PDF/A uploads. Since Google Drive uses IDs not paths, "
|
||
"this must be set separately. If empty, uses the standard google_drive_folder_id."
|
||
),
|
||
)
|
||
pdfa_timestamp_enabled: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable RFC 3161 timestamping of PDF/A files via a Timestamp Authority (TSA). "
|
||
"Creates a .tsr file alongside each PDF/A file for legal proof of existence. "
|
||
"Requires openssl binary on PATH. Default: False."
|
||
),
|
||
)
|
||
pdfa_timestamp_url: str = Field(
|
||
default="https://freetsa.org/tsr",
|
||
description=(
|
||
"URL of the RFC 3161 Timestamp Authority. Default: FreeTSA (https://freetsa.org/tsr). "
|
||
"Other options: GlobalSign, DigiStamp, or any RFC 3161-compliant TSA."
|
||
),
|
||
)
|
||
|
||
imap_readonly_mode: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"When enabled, IMAP processing will fetch and process attachments but will NOT modify "
|
||
"the mailbox state (no starring, labeling, deleting, or flag changes). "
|
||
"Use this for pre-production instances that share a mailbox with production to prevent "
|
||
"preprod from interfering with production email processing."
|
||
),
|
||
)
|
||
|
||
imap_attachment_filter: str = Field(
|
||
default="documents_only",
|
||
description=(
|
||
"Controls which attachment types are ingested from IMAP emails. "
|
||
"Accepted values: "
|
||
"'documents_only' – ingest only PDFs and office files (Word, Excel, PowerPoint, ODT, etc.); "
|
||
"'all' – ingest all supported file types including images. "
|
||
"This is the global default; individual user IMAP accounts can override it."
|
||
),
|
||
)
|
||
|
||
# Batch processing settings
|
||
processall_throttle_threshold: int = Field(
|
||
default=20,
|
||
description="Number of files above which throttling is applied in /processall endpoint",
|
||
)
|
||
processall_throttle_delay: int = Field(
|
||
default=3,
|
||
description="Delay in seconds between each task submission when throttling in /processall",
|
||
)
|
||
|
||
# Client-side upload throttling settings (applied when uploading files via the web UI)
|
||
upload_concurrency: int = Field(
|
||
default=3,
|
||
description=(
|
||
"Maximum number of files uploaded simultaneously from the browser. "
|
||
"Limits parallel uploads to prevent API overload when dragging directories. Default: 3."
|
||
),
|
||
)
|
||
upload_queue_delay_ms: int = Field(
|
||
default=500,
|
||
description=(
|
||
"Delay in milliseconds between starting each upload slot when queue is active. "
|
||
"Staggers upload starts to smooth out server load. Default: 500 ms."
|
||
),
|
||
)
|
||
|
||
# Notification settings
|
||
notification_urls: Union[List[str], str] = Field(
|
||
default_factory=list,
|
||
description="List of Apprise notification URLs (e.g., discord://, telegram://, etc.)",
|
||
)
|
||
notify_on_task_failure: bool = Field(default=True, description="Send notifications when Celery tasks fail")
|
||
notify_on_credential_failure: bool = Field(
|
||
default=True, description="Send notifications when credential checks fail"
|
||
)
|
||
notify_on_startup: bool = Field(default=True, description="Send notifications when application starts")
|
||
notify_on_shutdown: bool = Field(default=False, description="Send notifications when application shuts down")
|
||
notify_on_file_processed: bool = Field(
|
||
default=True,
|
||
description="Send notifications when files are successfully processed",
|
||
)
|
||
notify_on_user_signup: bool = Field(
|
||
default=True,
|
||
description="Send admin notifications when a new user signs up",
|
||
)
|
||
notify_on_plan_change: bool = Field(
|
||
default=True,
|
||
description="Send admin notifications when a user changes their subscription plan",
|
||
)
|
||
notify_on_payment_issue: bool = Field(
|
||
default=True,
|
||
description="Send admin notifications when a payment issue is reported for a user",
|
||
)
|
||
|
||
# Webhook settings
|
||
webhook_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable webhook delivery for document events",
|
||
)
|
||
|
||
# ── Backup / restore settings ──────────────────────────────────────────────
|
||
backup_enabled: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Enable automatic scheduled database backups. "
|
||
"When enabled, hourly, daily, and weekly backups are created automatically. Default: True."
|
||
),
|
||
)
|
||
backup_dir: Optional[str] = Field(
|
||
default=None,
|
||
description=("Directory where local backup archives are stored. Defaults to <workdir>/backups when not set."),
|
||
)
|
||
# Remote destination: one of s3, dropbox, google_drive, onedrive, nextcloud,
|
||
# webdav, ftp, sftp, email, or empty/None for local-only.
|
||
backup_remote_destination: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Storage provider to upload remote backup copies to. "
|
||
"Accepted values: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email. "
|
||
"Leave empty to keep backups local only."
|
||
),
|
||
)
|
||
backup_remote_folder: str = Field(
|
||
default="backups",
|
||
description=(
|
||
"Sub-folder / key prefix used when uploading backup archives to the remote destination. Default: 'backups'."
|
||
),
|
||
)
|
||
# Retention counts (number of snapshots to keep per tier)
|
||
backup_retain_hourly: int = Field(
|
||
default=96,
|
||
description="Number of hourly backups to retain (default 96 = 4 days × 24 h).",
|
||
)
|
||
backup_retain_daily: int = Field(
|
||
default=21,
|
||
description="Number of daily backups to retain (default 21 = 3 weeks).",
|
||
)
|
||
backup_retain_weekly: int = Field(
|
||
default=13,
|
||
description="Number of weekly backups to retain (default 13 ≈ 3 months / 91 days).",
|
||
)
|
||
|
||
# File upload size limits (for security - see SECURITY_AUDIT.md)
|
||
max_upload_size: int = Field(
|
||
default=1073741824, # 1GB in bytes (1024 * 1024 * 1024)
|
||
description="Maximum file upload size in bytes. Default: 1GB. Prevents resource exhaustion attacks.",
|
||
)
|
||
max_single_file_size: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Maximum size for a single file chunk in bytes. If set and file exceeds this,"
|
||
" it will be split into smaller chunks for processing. Default: None (no splitting)."
|
||
),
|
||
)
|
||
max_request_body_size: int = Field(
|
||
default=1048576, # 1MB in bytes (1024 * 1024)
|
||
description=(
|
||
"Maximum request body size in bytes for non-file-upload requests. Default: 1MB."
|
||
" Prevents memory exhaustion attacks via oversized JSON/form payloads."
|
||
" File uploads are governed by MAX_UPLOAD_SIZE instead."
|
||
),
|
||
)
|
||
|
||
# Deduplication settings - prevents processing of duplicate files
|
||
enable_deduplication: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Enable deduplication check before processing. If enabled, files with the same SHA-256 hash"
|
||
" as previously processed files will not be processed again. Default: True (enabled)."
|
||
),
|
||
)
|
||
show_deduplication_step: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Show the 'Check for Duplicates' step in processing history."
|
||
" If False, the check is still performed but not displayed. Default: True."
|
||
),
|
||
)
|
||
near_duplicate_threshold: float = Field(
|
||
default=0.85,
|
||
description=(
|
||
"Minimum cosine similarity score (0–1) between two documents' text embeddings to consider "
|
||
"them near-duplicates. Higher values require closer content matches. Default: 0.85."
|
||
),
|
||
)
|
||
embedding_model: str = Field(
|
||
default="text-embedding-3-small",
|
||
description=(
|
||
"Model name used for generating text embeddings via the OpenAI-compatible API. "
|
||
"Embeddings drive the document similarity feature. Default: text-embedding-3-small."
|
||
),
|
||
)
|
||
embedding_max_tokens: int = Field(
|
||
default=8000,
|
||
description=(
|
||
"Maximum number of tokens to send to the embedding model. "
|
||
"Text is truncated to approximately this many tokens (using a "
|
||
"conservative 3-chars-per-token estimate) before calling the API. "
|
||
"Set this below the model's context window (e.g. 8000 for an 8192-token model)."
|
||
),
|
||
)
|
||
embedding_backfill_batch_size: int = Field(
|
||
default=50,
|
||
description=(
|
||
"Maximum number of files to queue for embedding computation per "
|
||
"backfill run. Keeps the worker and embedding API load bounded."
|
||
),
|
||
)
|
||
|
||
# Text quality check - AI-based assessment of embedded PDF text
|
||
enable_text_quality_check: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Enable AI-based quality check for embedded PDF text. "
|
||
"When enabled, text extracted from non-digital PDFs is evaluated by the AI model. "
|
||
"If the text is poor quality (OCR artefacts, typos, incoherence), the file is "
|
||
"re-processed with OCR instead of using the embedded text. "
|
||
"Digitally-created PDFs (Word, LibreOffice, LaTeX, etc.) are always trusted and "
|
||
"bypass the check. Default: True (enabled)."
|
||
),
|
||
)
|
||
text_quality_threshold: int = Field(
|
||
default=85,
|
||
description=(
|
||
"Minimum quality score (0–100) required to accept embedded PDF text without re-OCR. "
|
||
"Text scoring below this threshold is discarded and the file is re-processed with OCR. "
|
||
"Default: 85. The stricter this value, the more files will be re-OCR'd."
|
||
),
|
||
)
|
||
text_quality_significant_issues: Union[List[str], str] = Field(
|
||
default_factory=lambda: ["excessive_typos", "garbage_characters", "incoherent_text", "fragmented_sentences"],
|
||
description=(
|
||
"Comma-separated list of quality issue labels that force OCR re-run even when the quality "
|
||
"score is above TEXT_QUALITY_THRESHOLD. Any of these issues present in the AI assessment "
|
||
"will trigger re-OCR. Default: excessive_typos,garbage_characters,incoherent_text,fragmented_sentences"
|
||
),
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Task retry settings (see app/tasks/retry_config.py)
|
||
# ---------------------------------------------------------------------------
|
||
task_retry_max_retries: int = Field(
|
||
default=3,
|
||
description=("Maximum number of automatic retry attempts for failed Celery tasks. Default: 3."),
|
||
)
|
||
task_retry_delays: Union[List[int], str] = Field(
|
||
default_factory=lambda: [60, 300, 900],
|
||
description=(
|
||
"Comma-separated list of retry countdown values in seconds. "
|
||
"Each value is the delay before the corresponding retry attempt. "
|
||
"If a task fails more times than entries in this list, the last delay "
|
||
"is doubled for each additional attempt. "
|
||
"Default: 60,300,900 (1 min, 5 min, 15 min)."
|
||
),
|
||
)
|
||
task_retry_jitter: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Apply ±20 % random jitter to retry countdowns to prevent "
|
||
"thundering-herd problems when many tasks fail simultaneously. "
|
||
"Default: True (enabled)."
|
||
),
|
||
)
|
||
|
||
# Processing step timeout - prevents files from getting stuck in "in_progress" state
|
||
step_timeout: int = Field(
|
||
default=600,
|
||
description=(
|
||
"Timeout in seconds for processing steps. If a step is 'in_progress' for longer than this,"
|
||
" it will be marked as failed. Default: 600 seconds (10 minutes)."
|
||
),
|
||
)
|
||
|
||
# Security Headers Configuration (see SECURITY_AUDIT.md and docs/DeploymentGuide.md)
|
||
# Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.)
|
||
# that already adds these headers. Enable if deploying directly without a reverse proxy.
|
||
security_headers_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable security headers middleware. Set to True if deploying without reverse proxy.",
|
||
)
|
||
|
||
# Strict-Transport-Security (HSTS) - Forces HTTPS connections
|
||
security_header_hsts_enabled: bool = Field(
|
||
default=True, description="Enable HSTS header. Only effective over HTTPS."
|
||
)
|
||
security_header_hsts_value: str = Field(
|
||
default="max-age=31536000; includeSubDomains",
|
||
description="HSTS header value. Default: 1 year with subdomains.",
|
||
)
|
||
|
||
# Content-Security-Policy (CSP) - Controls resource loading
|
||
security_header_csp_enabled: bool = Field(default=True, description="Enable CSP header.")
|
||
security_header_csp_value: str = Field(
|
||
default=(
|
||
"default-src 'self'; script-src 'self' 'unsafe-inline';"
|
||
" style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;"
|
||
),
|
||
description="CSP header value. Customize based on your application's resource loading needs.",
|
||
)
|
||
|
||
# X-Frame-Options - Prevents clickjacking
|
||
security_header_x_frame_options_enabled: bool = Field(default=True, description="Enable X-Frame-Options header.")
|
||
security_header_x_frame_options_value: str = Field(
|
||
default="DENY",
|
||
description="X-Frame-Options header value. Options: DENY, SAMEORIGIN, or ALLOW-FROM uri",
|
||
)
|
||
|
||
# X-Content-Type-Options - Prevents MIME sniffing
|
||
security_header_x_content_type_options_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable X-Content-Type-Options header (always set to 'nosniff').",
|
||
)
|
||
|
||
# Audit Logging Configuration (see SECURITY_AUDIT.md – Infrastructure Security)
|
||
# Logs every HTTP request and security-relevant events (auth failures, 5xx errors).
|
||
# Sensitive query-parameter values (passwords, tokens, keys) are always masked.
|
||
audit_logging_enabled: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Enable audit/request logging middleware. Logs every HTTP request with "
|
||
"method, path, status code, response time, and username. "
|
||
"Sensitive query-parameter values are automatically masked."
|
||
),
|
||
)
|
||
audit_log_include_client_ip: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Include the client IP address in audit log entries. "
|
||
"Disable for privacy-sensitive deployments where IP logging is restricted."
|
||
),
|
||
)
|
||
|
||
# SIEM / External Audit Log Forwarding
|
||
# Forward audit events to external SIEM systems for centralised monitoring.
|
||
audit_siem_enabled: bool = Field(
|
||
default=False,
|
||
description="Enable forwarding of audit events to an external SIEM system.",
|
||
)
|
||
audit_siem_transport: str = Field(
|
||
default="syslog",
|
||
description=(
|
||
"Transport used to forward audit events. "
|
||
"Options: 'syslog' (RFC 5424 over UDP/TCP), 'http' (JSON POST to a webhook URL, "
|
||
"compatible with Splunk HEC, Logstash HTTP input, Grafana Loki, etc.)."
|
||
),
|
||
)
|
||
audit_siem_syslog_host: str = Field(
|
||
default="localhost",
|
||
description="Hostname or IP of the syslog receiver.",
|
||
)
|
||
audit_siem_syslog_port: int = Field(
|
||
default=514,
|
||
description="Port of the syslog receiver.",
|
||
)
|
||
audit_siem_syslog_protocol: str = Field(
|
||
default="udp",
|
||
description="Protocol for syslog transport: 'udp' or 'tcp'.",
|
||
)
|
||
audit_siem_http_url: str = Field(
|
||
default="",
|
||
description=(
|
||
"HTTP endpoint URL for SIEM webhook delivery. "
|
||
"Supports Splunk HEC (https://splunk:8088/services/collector/event), "
|
||
"Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint."
|
||
),
|
||
)
|
||
audit_siem_http_token: str = Field(
|
||
default="",
|
||
description="Bearer / HEC token included in the Authorization header of SIEM HTTP requests.",
|
||
)
|
||
audit_siem_http_custom_headers: str = Field(
|
||
default="",
|
||
description="Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.",
|
||
)
|
||
|
||
# UI / Appearance
|
||
ui_default_color_scheme: str = Field(
|
||
default="system",
|
||
description=(
|
||
"Default color scheme for the web interface. "
|
||
"Options: 'system' (follow OS preference), 'light', 'dark'. "
|
||
"Individual users can override this with the in-app toggle; their choice is persisted in localStorage."
|
||
),
|
||
)
|
||
|
||
# Per-user upload rate limiting (health-aware, Redis-backed sliding window)
|
||
upload_rate_limit_per_user: int = Field(
|
||
default=20,
|
||
description=(
|
||
"Maximum number of file uploads allowed per user within the sliding window. "
|
||
"The effective limit may be reduced dynamically when the system is under heavy load "
|
||
"(high queue depth or CPU usage). Set to 0 to disable per-user upload rate limiting."
|
||
),
|
||
)
|
||
upload_rate_limit_window: int = Field(
|
||
default=60,
|
||
description="Sliding window size in seconds for per-user upload rate limiting (default: 60).",
|
||
)
|
||
|
||
# Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md)
|
||
# Protects against DoS attacks and API abuse
|
||
rate_limiting_enabled: bool = Field(
|
||
default=True,
|
||
description="Enable rate limiting middleware. Recommended for production to prevent abuse.",
|
||
)
|
||
rate_limit_default: str = Field(
|
||
default="100/minute",
|
||
description="Default rate limit for all endpoints (format: 'count/period', e.g., '100/minute', '1000/hour').",
|
||
)
|
||
rate_limit_upload: str = Field(
|
||
default="600/minute",
|
||
description="Rate limit for file upload endpoints to prevent resource exhaustion.",
|
||
)
|
||
rate_limit_auth: str = Field(
|
||
default="10/minute",
|
||
description="Stricter rate limit for authentication endpoints to prevent brute force attacks.",
|
||
)
|
||
|
||
# CORS Configuration (see SECURITY_AUDIT.md – Infrastructure Security section)
|
||
# Disabled by default since most deployments use a reverse proxy (Traefik, Nginx, etc.)
|
||
# that already adds CORS headers. Enable only if deploying without a reverse proxy or if
|
||
# the proxy does not handle CORS. See docs/DeploymentGuide.md for rationale.
|
||
cors_enabled: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable CORS middleware. Set to False if reverse proxy (Traefik, Nginx) handles CORS headers. "
|
||
"When True, CORSMiddleware is added to the application with the settings below."
|
||
),
|
||
)
|
||
cors_allowed_origins: Union[List[str], str] = Field(
|
||
default_factory=lambda: ["*"],
|
||
description=(
|
||
"List of allowed CORS origins. Use ['*'] to allow all origins (not recommended with "
|
||
"cors_allow_credentials=True). Comma-separated string is also accepted via env var, "
|
||
"e.g. CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com"
|
||
),
|
||
)
|
||
cors_allow_credentials: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Allow credentials (cookies, Authorization headers) in CORS requests. "
|
||
"Cannot be True when cors_allowed_origins=['*']. "
|
||
"When True, set cors_allowed_origins to specific origins."
|
||
),
|
||
)
|
||
cors_allowed_methods: Union[List[str], str] = Field(
|
||
default_factory=lambda: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||
description="Allowed HTTP methods for CORS requests.",
|
||
)
|
||
cors_allowed_headers: Union[List[str], str] = Field(
|
||
default_factory=lambda: ["*"],
|
||
description="Allowed request headers for CORS. Use ['*'] to allow all headers.",
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Support / Help Center – Zammad integration
|
||
# ---------------------------------------------------------------------------
|
||
zammad_url: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Base URL of your Zammad instance (e.g. https://zammad.example.com). "
|
||
"Required for the chat widget and feedback form on the Help Center page."
|
||
),
|
||
)
|
||
zammad_chat_enabled: bool = Field(
|
||
default=False,
|
||
description="Show the Zammad live-chat widget on the Help Center page.",
|
||
)
|
||
zammad_chat_id: int = Field(
|
||
default=1,
|
||
description="Zammad chat topic ID to use for the live-chat widget.",
|
||
)
|
||
zammad_form_enabled: bool = Field(
|
||
default=False,
|
||
description="Show the Zammad feedback / ticket form on the Help Center page.",
|
||
)
|
||
support_email: Optional[str] = Field(
|
||
default=None,
|
||
description="Support e-mail address displayed on the Help Center page.",
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Observability – Sentry error & performance monitoring
|
||
# ---------------------------------------------------------------------------
|
||
sentry_dsn: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Sentry Data Source Name (DSN). When set, error reporting and "
|
||
"performance tracing are enabled automatically. Leave blank (or unset) "
|
||
"to disable Sentry entirely."
|
||
),
|
||
)
|
||
sentry_environment: str = Field(
|
||
default="production",
|
||
description=(
|
||
"Environment tag sent to Sentry (e.g. 'development', 'staging', 'production'). "
|
||
"Helps you filter events in the Sentry dashboard."
|
||
),
|
||
)
|
||
sentry_traces_sample_rate: float = Field(
|
||
default=0.1,
|
||
description=(
|
||
"Fraction of transactions to capture for performance monitoring (0.0–1.0). "
|
||
"Set to 0.0 to disable tracing, 1.0 to capture every transaction. "
|
||
"Values above 0 may increase Sentry quota usage."
|
||
),
|
||
)
|
||
sentry_profiles_sample_rate: float = Field(
|
||
default=0.0,
|
||
description=(
|
||
"Fraction of profiled transactions to send to Sentry (0.0–1.0). "
|
||
"Profiling is only active when traces_sample_rate > 0. "
|
||
"Defaults to 0.0 (disabled) to minimise overhead."
|
||
),
|
||
)
|
||
sentry_send_default_pii: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Whether to attach personally identifiable information (PII) – such as "
|
||
"IP addresses and user agents – to Sentry events. Disabled by default "
|
||
"for privacy compliance (GDPR / CCPA). Enable only if your Sentry "
|
||
"project is configured to handle PII."
|
||
),
|
||
)
|
||
|
||
@model_validator(mode="before")
|
||
@classmethod
|
||
def strip_outer_quotes(cls, data: Any) -> Any:
|
||
"""
|
||
Strip matching surrounding quotes from string values.
|
||
|
||
In Kubernetes (and some other environments) env var values can arrive
|
||
with literal quote characters included, e.g. the value for DATABASE_URL
|
||
may be ``"postgresql://..."`` (with the quotes as part of the string)
|
||
rather than just ``postgresql://...``. Docker Compose strips these
|
||
automatically; Kubernetes does not.
|
||
"""
|
||
if isinstance(data, dict):
|
||
for key, value in data.items():
|
||
if isinstance(value, str) and len(value) >= 2:
|
||
if (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'"):
|
||
data[key] = value[1:-1]
|
||
return data
|
||
|
||
@field_validator("notification_urls", mode="before")
|
||
@classmethod
|
||
def parse_notification_urls(cls, v: str | list[str]) -> list[str]:
|
||
"""Parse notification URLs from string or list"""
|
||
if isinstance(v, str):
|
||
if "," in v:
|
||
return [url.strip() for url in v.split(",") if url.strip()]
|
||
elif v.strip():
|
||
return [v.strip()]
|
||
return []
|
||
return v
|
||
|
||
@field_validator("text_quality_significant_issues", mode="before")
|
||
@classmethod
|
||
def parse_text_quality_significant_issues(cls, v: str | list[str]) -> list[str]:
|
||
"""Parse significant issue labels from comma-separated string or list."""
|
||
if isinstance(v, str):
|
||
if "," in v:
|
||
return [item.strip() for item in v.split(",") if item.strip()]
|
||
elif v.strip():
|
||
return [v.strip()]
|
||
return []
|
||
return v
|
||
|
||
@field_validator("cors_allowed_origins", "cors_allowed_methods", "cors_allowed_headers", mode="before")
|
||
@classmethod
|
||
def parse_comma_separated_list(cls, v: str | list[str]) -> list[str]:
|
||
"""Parse comma-separated string or list for CORS list settings."""
|
||
if isinstance(v, str):
|
||
if "," in v:
|
||
return [item.strip() for item in v.split(",") if item.strip()]
|
||
elif v.strip():
|
||
return [v.strip()]
|
||
return []
|
||
return v
|
||
|
||
@field_validator("task_retry_delays", mode="before")
|
||
@classmethod
|
||
def parse_task_retry_delays(cls, v: str | list) -> list[int]:
|
||
"""Parse task retry delays from comma-separated string or list of ints."""
|
||
if isinstance(v, str):
|
||
parts = [p.strip() for p in v.split(",") if p.strip()]
|
||
return [int(p) for p in parts]
|
||
return [int(item) for item in v]
|
||
|
||
@field_validator("session_secret")
|
||
@classmethod
|
||
def validate_session_secret(cls, v: str | None, info: object) -> str | None:
|
||
"""Validate that session_secret is set and has sufficient length when auth is enabled"""
|
||
if info.data.get("auth_enabled") and not v:
|
||
raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True")
|
||
if info.data.get("auth_enabled") and v and len(v) < 32:
|
||
raise ValueError("SESSION_SECRET must be at least 32 characters long")
|
||
return v
|
||
|
||
# Get build date from environment or file
|
||
@property
|
||
def build_date(self) -> str:
|
||
# First try to get build date from environment
|
||
env_build_date = os.environ.get("BUILD_DATE")
|
||
if env_build_date:
|
||
return env_build_date
|
||
|
||
# Then try to get build date from BUILD_DATE file
|
||
build_date_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "BUILD_DATE")
|
||
if os.path.exists(build_date_file):
|
||
with open(build_date_file, "r") as f:
|
||
return f.read().strip()
|
||
|
||
# Default to unknown if not found
|
||
return "Unknown build date"
|
||
|
||
# Get version from file or environment
|
||
@property
|
||
def version(self) -> str:
|
||
# First try to get version from environment
|
||
env_version = os.environ.get("APP_VERSION")
|
||
if env_version:
|
||
return env_version
|
||
|
||
# Then try to get version from VERSION file
|
||
version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION")
|
||
if os.path.exists(version_file):
|
||
with open(version_file, "r") as f:
|
||
return f.read().strip()
|
||
|
||
# Default version if not found
|
||
return "unknown"
|
||
|
||
@property
|
||
def git_sha(self) -> str:
|
||
"""Get Git commit SHA from environment or file."""
|
||
# First try to get from environment
|
||
env_sha = os.environ.get("GIT_COMMIT_SHA")
|
||
if env_sha:
|
||
return env_sha
|
||
|
||
# Then try to get from GIT_SHA file
|
||
git_sha_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "GIT_SHA")
|
||
if os.path.exists(git_sha_file):
|
||
with open(git_sha_file, "r") as f:
|
||
return f.read().strip()
|
||
|
||
# Default if not found
|
||
return "unknown"
|
||
|
||
@property
|
||
def runtime_info(self) -> str:
|
||
"""Get runtime information from file."""
|
||
runtime_info_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "RUNTIME_INFO")
|
||
if os.path.exists(runtime_info_file):
|
||
with open(runtime_info_file, "r") as f:
|
||
return f.read().strip()
|
||
|
||
# Return basic info if file not found
|
||
return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}"
|
||
|
||
@property
|
||
def release_name(self) -> str | None:
|
||
"""Get the release codename for the current version from release_names.json.
|
||
|
||
Looks up the current version's minor version prefix (e.g., '0.5' for '0.5.3')
|
||
in release_names.json to find the associated codename. Returns None if no
|
||
codename is defined for the current version.
|
||
|
||
Returns:
|
||
The release codename string, or None if not found.
|
||
"""
|
||
version = self.version
|
||
if not version or version == "unknown":
|
||
return None
|
||
|
||
release_names_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json")
|
||
if not os.path.exists(release_names_file):
|
||
return None
|
||
|
||
try:
|
||
with open(release_names_file, "r") as f:
|
||
data = json.load(f)
|
||
|
||
releases = data.get("releases", {})
|
||
|
||
# Try exact version match first (e.g., "0.5.0")
|
||
if version in releases:
|
||
return releases[version].get("codename")
|
||
|
||
# Try minor version prefix (e.g., "0.5" for "0.5.3")
|
||
parts = version.split(".")
|
||
if len(parts) >= 2:
|
||
minor_prefix = f"{parts[0]}.{parts[1]}"
|
||
if minor_prefix in releases:
|
||
return releases[minor_prefix].get("codename")
|
||
|
||
# Try major version prefix (e.g., "1" for "1.0.0")
|
||
if len(parts) >= 1:
|
||
major_prefix = parts[0]
|
||
if major_prefix in releases:
|
||
return releases[major_prefix].get("codename")
|
||
|
||
return None
|
||
except (json.JSONDecodeError, KeyError, IndexError):
|
||
return None
|
||
|
||
|
||
settings = Settings()
|