"""Environment configuration; production fails closed without upload authentication.""" import os from dataclasses import dataclass from functools import lru_cache from pathlib import Path from urllib.parse import urlsplit @dataclass(frozen=True) class Settings: environment: str database_url: str storage_dir: Path api_key: str public_url: str max_upload_bytes: int parse_timeout: int link_ttl: int max_pending: int s3_bucket: str s3_endpoint: str | None s3_region: str storage_backend: str s3_prefix: str s3_addressing_style: str s3_connect_timeout: int s3_read_timeout: int s3_max_attempts: int s3_encryption: str s3_kms_key_id: str @lru_cache def settings(): root = Path(os.getenv('STORAGE_DIR', './data')).resolve() bucket = os.getenv('S3_BUCKET', '').strip() backend = os.getenv('STORAGE_BACKEND', 'auto').strip().lower() if backend == 'auto': backend = 's3' if bucket else 'local' if backend not in ('local', 's3'): raise RuntimeError('STORAGE_BACKEND must be auto, local or s3') if backend == 's3' and not bucket: raise RuntimeError('S3_BUCKET is required when STORAGE_BACKEND=s3') prefix = os.getenv('S3_PREFIX', '').strip('/') if (len(prefix.encode('utf-8')) > 512 or '\\' in prefix or any(ord(c) < 32 or ord(c) == 127 for c in prefix) or (prefix and any(part in ('', '.', '..') for part in prefix.split('/')))): raise RuntimeError('S3_PREFIX must be a relative object prefix without empty, dot or traversal segments (max 512 UTF-8 bytes)') config = Settings( environment=os.getenv('APP_ENV', 'development'), database_url=os.getenv('DATABASE_URL', f'sqlite:///{root}/jobs.db'), storage_dir=root, api_key=os.getenv('API_KEY', ''), public_url=os.getenv('PUBLIC_BASE_URL', 'http://127.0.0.1:8000').rstrip('/'), max_upload_bytes=int(os.getenv('MAX_UPLOAD_BYTES', str(32 * 1024 * 1024))), parse_timeout=int(os.getenv('PARSE_TIMEOUT_SECONDS', '60')), link_ttl=int(os.getenv('LINK_TTL_SECONDS', '604800')), max_pending=int(os.getenv('MAX_PENDING_JOBS', '100')), s3_bucket=bucket, s3_endpoint=os.getenv('S3_ENDPOINT_URL') or None, s3_region=os.getenv('S3_REGION') or os.getenv('AWS_DEFAULT_REGION') or 'us-east-1', storage_backend=backend, s3_prefix=prefix, s3_addressing_style=os.getenv('S3_ADDRESSING_STYLE', 'auto').strip().lower(), s3_connect_timeout=int(os.getenv('S3_CONNECT_TIMEOUT', '5')), s3_read_timeout=int(os.getenv('S3_READ_TIMEOUT', '60')), s3_max_attempts=int(os.getenv('S3_MAX_ATTEMPTS', '3')), s3_encryption=os.getenv('S3_SERVER_SIDE_ENCRYPTION', '').strip(), s3_kms_key_id=os.getenv('S3_KMS_KEY_ID', '').strip(), ) if config.environment == 'production' and len(config.api_key) < 32: raise RuntimeError('Production requires API_KEY with at least 32 characters') if config.environment == 'production' and not config.public_url.startswith('https://'): raise RuntimeError('Production requires an HTTPS PUBLIC_BASE_URL') if min(config.max_upload_bytes, config.parse_timeout, config.link_ttl, config.max_pending) <= 0: raise RuntimeError('Upload, parse, expiry and queue limits must be positive') if config.s3_addressing_style not in ('auto', 'path', 'virtual'): raise RuntimeError('S3_ADDRESSING_STYLE must be auto, path or virtual') if min(config.s3_connect_timeout, config.s3_read_timeout, config.s3_max_attempts) <= 0: raise RuntimeError('S3 timeouts and max attempts must be positive') if config.s3_encryption not in ('', 'AES256', 'aws:kms'): raise RuntimeError('S3_SERVER_SIDE_ENCRYPTION must be empty, AES256 or aws:kms') if config.s3_kms_key_id and config.s3_encryption != 'aws:kms': raise RuntimeError('S3_KMS_KEY_ID requires S3_SERVER_SIDE_ENCRYPTION=aws:kms') if config.s3_endpoint: url = urlsplit(config.s3_endpoint) if url.scheme not in ('http', 'https') or not url.hostname or url.username or url.password or url.query or url.fragment: raise RuntimeError('S3_ENDPOINT_URL must be an HTTP(S) service endpoint without credentials, query or fragment') if backend == 'local' or config.database_url.startswith('sqlite:'): root.mkdir(parents=True, exist_ok=True, mode=0o700) return config