from __future__ import annotations import ipaddress import re from functools import cached_property from pathlib import Path from urllib.parse import urlsplit from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict FIELD_RE = re.compile(r"^UF_CRM_[A-Za-z0-9]+$") MEMBER_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$") def _read_secret(value: SecretStr | None, path: Path | None) -> SecretStr | None: if value and value.get_secret_value(): return value if path: text = path.read_text(encoding="utf-8").strip() return SecretStr(text) if text else None return None class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="BITRIX_SYNC_", extra="ignore") enabled: bool = False mode: str = "disabled" database_url: SecretStr | None = None database_url_file: Path | None = None crm_rest_webhook_url: SecretStr | None = None crm_rest_webhook_url_file: Path | None = None contact_receiver_token: SecretStr | None = None contact_receiver_token_file: Path | None = None contact_receiver_previous_token: SecretStr | None = None alert_receiver_token: SecretStr | None = None alert_receiver_token_file: Path | None = None alert_receiver_previous_token: SecretStr | None = None service_token: SecretStr | None = None service_token_file: Path | None = None portal_host: str | None = None portal_member_id: str | None = None public_base_url: str | None = None contact_user_id_field: str | None = None contact_registered_field: str | None = None contact_citizenship_field: str | None = None webhook_allowed_cidrs: str = "" http_timeout_sec: float = Field(default=10, ge=1, le=60) db_pool_size: int = Field(default=5, ge=1, le=30) webhook_max_body_bytes: int = Field(default=16_384, ge=1024, le=65_536) webhook_max_fields: int = Field(default=24, ge=8, le=64) lease_seconds: int = Field(default=60, ge=10, le=600) claim_size: int = Field(default=20, ge=1, le=100) batch_size: int = Field(default=20, ge=1, le=50) batch_wait_ms: int = Field(default=200, ge=10, le=5000) limiter_refill_per_sec: float = Field(default=2, gt=0, le=50) limiter_burst: int = Field(default=2, ge=1, le=50) max_in_flight: int = Field(default=2, ge=1, le=20) retry_base_seconds: float = Field(default=1, ge=0.1, le=60) retry_max_seconds: float = Field(default=900, ge=1, le=3600) retry_horizon_seconds: int = Field(default=86_400, ge=60, le=604_800) reconciliation_overlap_seconds: int = Field(default=300, ge=0, le=3600) reconciliation_interval_seconds: int = Field(default=900, ge=60, le=86_400) @model_validator(mode="after") def validate_mode(self) -> Settings: self.database_url = _read_secret(self.database_url, self.database_url_file) self.crm_rest_webhook_url = _read_secret( self.crm_rest_webhook_url, self.crm_rest_webhook_url_file ) self.contact_receiver_token = _read_secret( self.contact_receiver_token, self.contact_receiver_token_file ) self.alert_receiver_token = _read_secret( self.alert_receiver_token, self.alert_receiver_token_file ) self.service_token = _read_secret(self.service_token, self.service_token_file) expected_mode = "full" if self.enabled else "disabled" if self.mode != expected_mode: raise ValueError(f"mode must be {expected_mode!r} when enabled={self.enabled}") if not self.enabled: return self required = { "database_url": self.database_url, "crm_rest_webhook_url": self.crm_rest_webhook_url, "contact_receiver_token": self.contact_receiver_token, "alert_receiver_token": self.alert_receiver_token, "service_token": self.service_token, "portal_host": self.portal_host, "portal_member_id": self.portal_member_id, "public_base_url": self.public_base_url, "contact_user_id_field": self.contact_user_id_field, "contact_registered_field": self.contact_registered_field, "contact_citizenship_field": self.contact_citizenship_field, } missing = [name for name, value in required.items() if not value] if missing: raise ValueError("missing full-mode settings: " + ", ".join(missing)) for name in ( "contact_user_id_field", "contact_registered_field", "contact_citizenship_field", ): if not FIELD_RE.fullmatch(str(getattr(self, name))): raise ValueError(f"{name} must match UF_CRM_") if not MEMBER_RE.fullmatch(str(self.portal_member_id)): raise ValueError("portal_member_id has invalid format") crm = urlsplit(self.crm_rest_webhook_url.get_secret_value()) public = urlsplit(str(self.public_base_url)) if crm.scheme != "https" or crm.hostname != self.portal_host or crm.port not in (None, 443): raise ValueError("CRM URL must be HTTPS on the approved portal host") if public.scheme != "https" or not public.hostname or public.query or public.fragment: raise ValueError("public_base_url must be a query-free HTTPS origin") if not self.allowed_networks: raise ValueError("webhook_allowed_cidrs cannot be empty in full mode") return self @cached_property def allowed_networks(self) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: values = [item.strip() for item in self.webhook_allowed_cidrs.split(",") if item.strip()] return tuple(ipaddress.ip_network(item, strict=True) for item in values) @staticmethod def rest_field_name(field: str) -> str: if not FIELD_RE.fullmatch(field): raise ValueError("invalid Bitrix custom field") return "ufCrm_" + field.removeprefix("UF_CRM_") def load_settings() -> Settings: return Settings()