Merge pull request #471 from christianlouis/copilot/update-settings-page-options

feat(settings): expose all 181 config settings on the settings page
This commit is contained in:
Christian Krakau-Louis
2026-03-02 18:44:12 +01:00
committed by GitHub
4 changed files with 653 additions and 8 deletions
+532 -1
View File
@@ -835,6 +835,16 @@ SETTING_METADATA = {
"sensitive": False,
"required": False,
"restart_required": False,
"options": [
"STANDARD",
"REDUCED_REDUNDANCY",
"STANDARD_IA",
"ONEZONE_IA",
"INTELLIGENT_TIERING",
"GLACIER",
"DEEP_ARCHIVE",
"GLACIER_IR",
],
},
"s3_acl": {
"category": "Storage Providers",
@@ -843,6 +853,15 @@ SETTING_METADATA = {
"sensitive": False,
"required": False,
"restart_required": False,
"options": [
"private",
"public-read",
"public-read-write",
"authenticated-read",
"aws-exec-read",
"bucket-owner-read",
"bucket-owner-full-control",
],
},
# Email Settings
"email_host": {
@@ -1111,10 +1130,13 @@ SETTING_METADATA = {
"Text scoring below this threshold triggers a fresh OCR pass. "
"Default: 85. Lower values are more permissive; higher values enforce stricter quality."
),
"type": "integer",
"type": "slider",
"sensitive": False,
"required": False,
"restart_required": False,
"min": 0,
"max": 100,
"step": 1,
},
"text_quality_significant_issues": {
"category": "Processing",
@@ -1187,6 +1209,22 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"enable_search": {
"category": "Feature Flags",
"description": "Enable Meilisearch full-text search integration. Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"webhook_enabled": {
"category": "Feature Flags",
"description": "Enable webhook delivery for document events. Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# UI / Appearance
"ui_default_color_scheme": {
"category": "UI",
@@ -1201,6 +1239,487 @@ SETTING_METADATA = {
"restart_required": False,
"options": ["system", "light", "dark"],
},
# Authentication additional
"admin_group_name": {
"category": "Authentication",
"description": "Name of the admin group in the OAuth provider (e.g. Authentik). Default: admin.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Storage Providers S3 (additional)
# (s3_bucket_name, s3_folder_prefix, s3_storage_class, s3_acl already in Storage Providers above)
# Paperless additional
"paperless_custom_field_absender": {
"category": "Storage Providers",
"description": "Name of the 'absender' (sender) custom field in Paperless-ngx.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"paperless_custom_fields_mapping": {
"category": "Storage Providers",
"description": (
"JSON mapping of metadata field names to Paperless custom field names. "
'Example: {"absender": "Sender", "empfaenger": "Recipient", "language": "Language"}.'
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Meilisearch / Search
"meilisearch_url": {
"category": "Core",
"description": "URL of the Meilisearch search engine instance. Default: http://meilisearch:7700.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"meilisearch_api_key": {
"category": "Core",
"description": "Meilisearch API key (master key or search-only key). Optional for local development.",
"type": "string",
"sensitive": True,
"required": False,
"restart_required": True,
},
"meilisearch_index_name": {
"category": "Core",
"description": "Name of the Meilisearch index for documents. Default: documents.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# PDF/A Archival Conversion
"enable_pdfa_conversion": {
"category": "PDF/A Archival",
"description": (
"Master switch: enable PDF/A archival variant generation. "
"When enabled, PDF/A copies of both the original and processed files are created. "
"Uses ocrmypdf with Ghostscript for the conversion. Default: False."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"pdfa_format": {
"category": "PDF/A Archival",
"description": "PDF/A format variant to produce: 1 = PDF/A-1b, 2 = PDF/A-2b, 3 = PDF/A-3b. Default: 2.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
"options": ["1", "2", "3"],
},
"pdfa_upload_original": {
"category": "PDF/A Archival",
"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."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"pdfa_upload_processed": {
"category": "PDF/A Archival",
"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."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"pdfa_upload_folder": {
"category": "PDF/A Archival",
"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', files go to '/Documents/pdfa'. "
"Set to empty string to upload into the same folder. Default: pdfa."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"google_drive_pdfa_folder_id": {
"category": "PDF/A Archival",
"description": (
"Google Drive folder ID for PDF/A uploads (Drive uses IDs not paths). "
"If empty, uses the standard google_drive_folder_id."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"pdfa_timestamp_enabled": {
"category": "PDF/A Archival",
"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."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"pdfa_timestamp_url": {
"category": "PDF/A Archival",
"description": (
"URL of the RFC 3161 Timestamp Authority (TSA). "
"Default: FreeTSA (https://freetsa.org/tsr). Other options: GlobalSign, DigiStamp, "
"or any RFC 3161-compliant TSA."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Deduplication
"enable_deduplication": {
"category": "Processing",
"description": (
"Enable deduplication check before processing. Files with the same SHA-256 hash "
"as previously processed files will be skipped. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"show_deduplication_step": {
"category": "Processing",
"description": (
"Show the 'Check for Duplicates' step in processing history. "
"If False, the check is still performed but not displayed. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"near_duplicate_threshold": {
"category": "Processing",
"description": (
"Minimum cosine similarity score (0.01.0) between two documents' text embeddings "
"to consider them near-duplicates. Higher values require closer content matches. Default: 0.85."
),
"type": "slider",
"sensitive": False,
"required": False,
"restart_required": False,
"min": 0,
"max": 1,
"step": 0.01,
},
# Embeddings
"embedding_model": {
"category": "AI Services",
"description": (
"Model name used for generating text embeddings via the OpenAI-compatible API. "
"Embeddings drive the document similarity feature. Default: text-embedding-3-small."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"embedding_max_tokens": {
"category": "AI Services",
"description": (
"Maximum number of tokens to send to the embedding model. "
"Text is truncated to approximately this many tokens before calling the API. Default: 8000."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
"embedding_backfill_batch_size": {
"category": "AI Services",
"description": (
"Maximum number of files to queue for embedding computation per backfill run. "
"Keeps the worker and embedding API load bounded. Default: 50."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
# File upload size limits
"max_upload_size": {
"category": "Core",
"description": (
"Maximum file upload size in bytes. Prevents resource exhaustion attacks. Default: 1073741824 (1 GB)."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"max_single_file_size": {
"category": "Core",
"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)."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"max_request_body_size": {
"category": "Core",
"description": (
"Maximum request body size in bytes for non-file-upload requests. "
"Prevents memory exhaustion via oversized JSON/form payloads. Default: 1048576 (1 MB)."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Task Retry Settings
"task_retry_max_retries": {
"category": "Processing",
"description": "Maximum number of automatic retry attempts for failed Celery tasks. Default: 3.",
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": True,
},
"task_retry_delays": {
"category": "Processing",
"description": (
"Comma-separated list of retry countdown values in seconds. "
"Each value is the delay before the corresponding retry attempt. "
"Default: 60,300,900 (1 min, 5 min, 15 min)."
),
"type": "list",
"sensitive": False,
"required": False,
"restart_required": True,
},
"task_retry_jitter": {
"category": "Processing",
"description": (
"Apply ±20% random jitter to retry countdowns to prevent thundering-herd problems. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"step_timeout": {
"category": "Processing",
"description": (
"Timeout in seconds for processing steps. Steps stuck in 'in_progress' longer "
"than this are marked as failed. Default: 600 (10 minutes)."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Security Headers
"security_headers_enabled": {
"category": "Security",
"description": (
"Enable security headers middleware. Set to True if deploying without a reverse proxy. "
"Default: False (most deployments use Traefik/Nginx which add headers)."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"security_header_hsts_enabled": {
"category": "Security",
"description": "Enable HTTP Strict Transport Security (HSTS) header. Only effective over HTTPS. Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"security_header_hsts_value": {
"category": "Security",
"description": "HSTS header value. Default: max-age=31536000; includeSubDomains.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"security_header_csp_enabled": {
"category": "Security",
"description": "Enable Content-Security-Policy (CSP) header. Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"security_header_csp_value": {
"category": "Security",
"description": (
"CSP header value. Customize based on your application's resource loading needs. "
"Default: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; font-src 'self' data:;"
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"security_header_x_frame_options_enabled": {
"category": "Security",
"description": "Enable X-Frame-Options header to prevent clickjacking. Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"security_header_x_frame_options_value": {
"category": "Security",
"description": "X-Frame-Options header value.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
"options": ["DENY", "SAMEORIGIN"],
},
"security_header_x_content_type_options_enabled": {
"category": "Security",
"description": "Enable X-Content-Type-Options header (always set to 'nosniff'). Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Audit Logging
"audit_logging_enabled": {
"category": "Security",
"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. "
"Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"audit_log_include_client_ip": {
"category": "Security",
"description": (
"Include the client IP address in audit log entries. "
"Disable for privacy-sensitive deployments where IP logging is restricted. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
# Rate Limiting
"rate_limiting_enabled": {
"category": "Security",
"description": "Enable rate limiting middleware. Recommended for production to prevent abuse. Default: True.",
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"rate_limit_default": {
"category": "Security",
"description": (
"Default rate limit for all endpoints (format: 'count/period', e.g. '100/minute'). Default: 100/minute."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"rate_limit_upload": {
"category": "Security",
"description": "Rate limit for file upload endpoints to prevent resource exhaustion. Default: 600/minute.",
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
"rate_limit_auth": {
"category": "Security",
"description": (
"Stricter rate limit for authentication endpoints to prevent brute force attacks. Default: 10/minute."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": True,
},
# CORS
"cors_enabled": {
"category": "Security",
"description": (
"Enable CORS middleware. Set to False if reverse proxy (Traefik, Nginx) handles CORS headers. "
"Default: False."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"cors_allowed_origins": {
"category": "Security",
"description": (
"Comma-separated list of allowed CORS origins. Use '*' to allow all origins "
"(not recommended with cors_allow_credentials=True). "
"Default: * (all origins)."
),
"type": "list",
"sensitive": False,
"required": False,
"restart_required": True,
},
"cors_allow_credentials": {
"category": "Security",
"description": (
"Allow credentials (cookies, Authorization headers) in CORS requests. "
"Cannot be True when cors_allowed_origins is '*'. Default: False."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": True,
},
"cors_allowed_methods": {
"category": "Security",
"description": (
"Comma-separated list of allowed HTTP methods for CORS requests. "
"Default: GET,POST,PUT,DELETE,OPTIONS,PATCH."
),
"type": "list",
"sensitive": False,
"required": False,
"restart_required": True,
},
"cors_allowed_headers": {
"category": "Security",
"description": "Comma-separated list of allowed request headers for CORS. Use '*' to allow all. Default: *.",
"type": "list",
"sensitive": False,
"required": False,
"restart_required": True,
},
}
@@ -1458,6 +1977,18 @@ def validate_setting_value(key: str, value: str) -> Tuple[bool, Optional[str]]:
except ValueError:
return False, f"{key} must be an integer"
elif setting_type == "slider":
try:
num = float(value)
min_val = metadata.get("min")
max_val = metadata.get("max")
if min_val is not None and num < min_val:
return False, f"{key} must be >= {min_val}"
if max_val is not None and num > max_val:
return False, f"{key} must be <= {max_val}"
except ValueError:
return False, f"{key} must be a number"
# Special validation for specific keys
if key == "session_secret" and value and len(value) < 32:
return False, "session_secret must be at least 32 characters"
+15 -7
View File
@@ -24,23 +24,31 @@ Each setting in the UI shows a badge indicating its current source:
Settings are organized into logical categories for easy navigation:
- **Core**: Database, Redis, working directory, external hostname, debug mode
- **Authentication**: Login settings, session secrets, OAuth configuration
- **AI Services**: AI provider selection and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM)
- **Core**: Database, Redis, working directory, external hostname, debug mode, Meilisearch, file size limits
- **Authentication**: Login settings, session secrets, OAuth configuration, admin group
- **AI Services**: AI provider selection, model configuration, embeddings, and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM)
- **OCR Engines**: OCR provider selection and configuration (Tesseract, EasyOCR, Mistral, Google DocAI, AWS Textract)
- **Storage Providers**: Dropbox, Google Drive, OneDrive, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless
- **Email**: SMTP configuration for sending emails
- **IMAP**: Email ingestion configuration (supports multiple accounts)
- **IMAP**: Email ingestion configuration (supports two mailbox accounts)
- **Monitoring**: Uptime Kuma integration
- **Notifications**: Apprise notification URLs and settings
- **Processing**: Batch processing and HTTP timeout settings
- **Feature Flags**: Enable/disable specific features
- **Processing**: Batch processing, upload throttling, deduplication, text quality, task retry, timeouts
- **Feature Flags**: Enable/disable specific features (file deletion, search, webhooks)
- **PDF/A Archival**: PDF/A conversion, upload, and RFC 3161 timestamping settings
- **Security**: Security headers, audit logging, rate limiting, CORS configuration
- **UI**: Color scheme preferences
### Setting Types
- **String**: Text values (API keys, URLs, paths)
- **Boolean**: True/false toggles (enable/disable features)
- **Integer**: Numeric values (ports, timeouts, thresholds)
- **List**: Comma-separated values (notification URLs)
- **Slider**: Numeric values with visual range controls (e.g., similarity threshold 01, quality threshold 0100)
- **Dropdown**: Predefined option lists (e.g., PDF/A format, S3 storage class, S3 ACL)
- **Multi-select**: Comma-separated selections from a list (e.g., OCR providers)
- **Model Picker**: Free-text with suggested model names (e.g., AI model selection)
- **List**: Comma-separated values (notification URLs, CORS origins)
### Sensitive Data
+23
View File
@@ -139,6 +139,29 @@
Enable {{ setting.key.replace('_', ' ').title() }}
</label>
</div>
{% elif setting.metadata.type == 'slider' %}
<!-- Slider Input -->
<div class="flex items-center gap-4">
<input
type="range"
id="{{ setting.key }}"
name="{{ setting.key }}"
min="{{ setting.metadata.get('min', 0) }}"
max="{{ setting.metadata.get('max', 100) }}"
step="{{ setting.metadata.get('step', 1) }}"
x-model="formData['{{ setting.key }}']"
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
<input
type="number"
min="{{ setting.metadata.get('min', 0) }}"
max="{{ setting.metadata.get('max', 100) }}"
step="{{ setting.metadata.get('step', 1) }}"
x-model="formData['{{ setting.key }}']"
class="setting-input w-24 px-2 py-1 border border-gray-300 rounded-md shadow-sm text-center focus:outline-none focus:ring-blue-500 focus:border-blue-500"
aria-label="{{ setting.key.replace('_', ' ').title() }} value"
/>
</div>
{% elif setting.metadata.type == 'multiselect' and setting.metadata.options %}
<!-- Multi-select checkboxes: stored as comma-separated string -->
<div class="flex flex-wrap gap-2 mt-1">
+83
View File
@@ -151,6 +151,89 @@ class TestSettingsService:
for setting in critical_settings:
assert setting in SETTING_METADATA, f"Missing metadata for {setting}"
def test_all_config_settings_have_metadata(self):
"""Test that every setting in config.py's Settings class has a SETTING_METADATA entry."""
from app.config import Settings
# Get all annotated fields from the Settings class
settings_fields = set(Settings.model_fields.keys())
metadata_keys = set(SETTING_METADATA.keys())
missing = settings_fields - metadata_keys
assert not missing, (
f"The following config.py settings are missing from SETTING_METADATA "
f"and will not appear on the settings page: {sorted(missing)}"
)
def test_setting_metadata_has_required_fields(self):
"""Test that every SETTING_METADATA entry has the required keys."""
required_keys = {"category", "description", "type", "sensitive", "required", "restart_required"}
for key, meta in SETTING_METADATA.items():
missing = required_keys - set(meta.keys())
assert not missing, f"SETTING_METADATA['{key}'] is missing keys: {missing}"
def test_pdfa_settings_in_metadata(self):
"""Test that all 8 PDF/A settings from the issue are present in SETTING_METADATA."""
pdfa_settings = [
"enable_pdfa_conversion",
"pdfa_format",
"pdfa_upload_original",
"pdfa_upload_processed",
"pdfa_upload_folder",
"google_drive_pdfa_folder_id",
"pdfa_timestamp_enabled",
"pdfa_timestamp_url",
]
for setting in pdfa_settings:
assert setting in SETTING_METADATA, f"PDF/A setting '{setting}' missing from SETTING_METADATA"
def test_pdfa_format_has_dropdown_options(self):
"""Test that pdfa_format uses a dropdown with valid PDF/A format options."""
meta = SETTING_METADATA["pdfa_format"]
assert "options" in meta, "pdfa_format should have dropdown options"
assert set(meta["options"]) == {"1", "2", "3"}, "pdfa_format options should be 1, 2, 3"
def test_slider_type_settings_have_range(self):
"""Test that slider-type settings have min, max, and step defined."""
for key, meta in SETTING_METADATA.items():
if meta.get("type") == "slider":
assert "min" in meta, f"Slider setting '{key}' missing 'min'"
assert "max" in meta, f"Slider setting '{key}' missing 'max'"
assert "step" in meta, f"Slider setting '{key}' missing 'step'"
def test_validate_slider_value(self):
"""Test validation of slider type values."""
# near_duplicate_threshold is a slider (0-1)
is_valid, error = validate_setting_value("near_duplicate_threshold", "0.85")
assert is_valid is True
is_valid, error = validate_setting_value("near_duplicate_threshold", "1.5")
assert is_valid is False
assert "must be <= 1" in error
is_valid, error = validate_setting_value("near_duplicate_threshold", "-0.1")
assert is_valid is False
assert "must be >= 0" in error
is_valid, error = validate_setting_value("near_duplicate_threshold", "not_a_number")
assert is_valid is False
assert "must be a number" in error
def test_s3_storage_class_has_dropdown_options(self):
"""Test that s3_storage_class uses a dropdown with valid options."""
meta = SETTING_METADATA["s3_storage_class"]
assert "options" in meta, "s3_storage_class should have dropdown options"
assert "STANDARD" in meta["options"]
assert "GLACIER" in meta["options"]
def test_s3_acl_has_dropdown_options(self):
"""Test that s3_acl uses a dropdown with valid options."""
meta = SETTING_METADATA["s3_acl"]
assert "options" in meta, "s3_acl should have dropdown options"
assert "private" in meta["options"]
assert "public-read" in meta["options"]
@pytest.mark.unit
class TestConfigLoader: