138 lines
6.0 KiB
Python
138 lines
6.0 KiB
Python
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_<latin letters or digits>")
|
|
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()
|