refactor(config): migrer CalDAV vers l'endpoint commun #59

Merged
Codex merged 1 commits from refactor/issue-16-caldav-endpoint into main 2026-09-13 12:10:42 +02:00
5 changed files with 117 additions and 44 deletions
Showing only changes of commit 93f66d6aca - Show all commits
+3 -1
View File
@@ -28,7 +28,9 @@ PRONOTE_AUTH_MODE=password
# PRONOTE_ACCOUNT_PIN= # PRONOTE_ACCOUNT_PIN=
# --- CalDAV --- # --- CalDAV ---
CALDAV_URL=https://caldav.example.com/calendars/user/pronote/ # Endpoint commun (URL potentiellement sensible, masquée dans les journaux)
CALDAV_ENDPOINT__URL=https://caldav.example.com/calendars/user/pronote/
# Ancien nom temporairement supporté avec un avertissement de dépréciation : CALDAV_URL
CALDAV_USERNAME=user@example.com CALDAV_USERNAME=user@example.com
CALDAV_PASSWORD=your_caldav_password CALDAV_PASSWORD=your_caldav_password
CALDAV_CALENDAR_PATH=/pronote-sync/ CALDAV_CALENDAR_PATH=/pronote-sync/
+1 -1
View File
@@ -264,7 +264,7 @@ Le projet utilise **`pydantic-settings`** pour valider et charger la configurati
| `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` | | `PRONOTE_USERNAME` | Identifiant Pronote (si `pronotepy` utilisé). | `parent.dupont` | `str` |
| `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` | | `PRONOTE_PASSWORD` | Mot de passe Pronote (si `pronotepy` utilisé). | `SecretStr` (masqué) | `SecretStr` |
| `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` | | `PRONOTE_ENT` | Slug ENT supporté, résolu vers une fonction de `pronotepy.ent`. | `monbureaunumerique` | `str` |
| `CALDAV_URL` | URL du serveur CalDAV (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `SecretStr` | | `CALDAV_ENDPOINT__URL` | URL du serveur CalDAV, via l'endpoint commun (masquée en `SecretStr`). | `https://caldav.example.com/calendars/...` | `ExternalEndpoint` |
| `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` | | `CALDAV_USERNAME` | Identifiant CalDAV. | `user@example.com` | `str` |
| `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` | | `CALDAV_PASSWORD` | Mot de passe CalDAV. | `SecretStr` (masqué) | `SecretStr` |
| `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` | | `CALDAV_CALENDAR_PATH` | Chemin du calendrier CalDAV de destination. | `/pronote-sync/` | `str` |
+109 -39
View File
@@ -10,21 +10,76 @@ from __future__ import annotations
import warnings import warnings
from datetime import date from datetime import date
from typing import Literal from typing import Annotated, Literal
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import ( from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field, Field,
SecretStr, SecretStr,
ValidationInfo,
field_serializer, field_serializer,
field_validator,
model_validator, model_validator,
) )
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url from pronote_sync.utils.redaction import redact_url
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"file", "http", "https"})
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
def _validate_external_endpoint_url(value: SecretStr) -> SecretStr:
"""Valide la structure et le schéma d'une URL d'endpoint externe.
:param value: URL potentiellement sensible à valider.
:return: URL validée, toujours encapsulée dans ``SecretStr``.
:rtype: SecretStr
:raises ValueError: Si l'URL est malformée ou utilise un schéma inconnu.
"""
is_valid = False
try:
parsed = urlparse(value.get_secret_value())
_ = parsed.port
is_valid = (
parsed.scheme in _EXTERNAL_ENDPOINT_SCHEMES
and (parsed.scheme not in {"http", "https"} or parsed.hostname is not None)
and (parsed.scheme != "file" or bool(parsed.path))
)
except ValueError:
pass
if not is_valid:
raise ValueError("Endpoint externe invalide : URL ou schéma non supporté") from None
return value
EndpointUrl = Annotated[SecretStr, AfterValidator(_validate_external_endpoint_url)]
class ExternalEndpoint(BaseModel):
"""Représente un endpoint externe potentiellement sensible.
Le socle accepte les transports ``https``, ``http`` et ``file``. Chaque
connecteur restreint ensuite cette liste selon sa propre politique de
sécurité. L'URL reste encapsulée dans :class:`pydantic.SecretStr` et sa
sérialisation conserve uniquement une représentation expurgée.
"""
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
url: EndpointUrl
@field_serializer("url")
def _serialize_url(self, value: SecretStr) -> str:
"""Expurge l'URL lors de la sérialisation.
:param value: URL encapsulée à sérialiser.
:return: URL expurgée.
:rtype: str
"""
return redact_url(value.get_secret_value())
class PronoteSettings(BaseSettings): class PronoteSettings(BaseSettings):
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``). """Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
@@ -91,73 +146,88 @@ class CalDAVSettings(BaseSettings):
Les variables d'environnement correspondantes sont préfixées par Les variables d'environnement correspondantes sont préfixées par
``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même ``CALDAV_``. L'URL est traitée comme potentiellement sensible (au même
titre que ``PRONOTE_ICAL_URL``) : elle est de type ``SecretStr`` et titre que ``PRONOTE_ICAL_ENDPOINT__URL``) : elle est de type ``SecretStr`` et
masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ; masquée lors de la sérialisation. Par défaut, seul HTTPS est accepté ;
HTTP n'est toléré que pour un hôte de boucle locale (``localhost``, HTTP n'est toléré que pour un hôte de boucle locale (``localhost``,
``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``. ``127.0.0.1``, ``::1``) lorsque ``allow_insecure_http`` vaut ``True``.
Le nouvel endpoint se configure avec ``CALDAV_ENDPOINT__URL`` ;
``CALDAV_URL`` reste temporairement pris en charge avec un avertissement
de dépréciation.
""" """
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_") model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
env_prefix="CALDAV_",
)
allow_insecure_http: bool = False allow_insecure_http: bool = False
url: SecretStr | None = None endpoint: ExternalEndpoint | None = None
url: SecretStr | None = Field(
default=None,
exclude=True,
deprecated="Utiliser endpoint.url à la place (CALDAV_URL obsolète).",
)
username: str | None = None username: str | None = None
password: SecretStr | None = None password: SecretStr | None = None
calendar_path: str = "/pronote-sync/" calendar_path: str = "/pronote-sync/"
@field_serializer("url") @model_validator(mode="before")
def _serialize_url(self, value: SecretStr | None) -> str | None:
"""Masque l'URL CalDAV lors de la sérialisation (repr, str, JSON).
:param value: Valeur du champ ``url`` (secret potentiel).
:return: URL avec les éléments sensibles remplacés par ``REDACTED``,
ou ``None`` si la valeur est absente.
:rtype: str | None
"""
if value is None:
return None
return redact_url(value.get_secret_value())
@field_validator("url")
@classmethod @classmethod
def _validate_url_https(cls, v: SecretStr | None, info: ValidationInfo) -> SecretStr | None: def _migrate_legacy_url(cls, data: object) -> object:
"""Valide le schéma de l'URL CalDAV (HTTPS obligatoire par défaut). """Migre ``url`` vers l'endpoint commun avec un avertissement.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
:rtype: object
"""
if not isinstance(data, dict) or data.get("url") is None:
return data
migrated_data = data.copy()
warnings.warn(
"CALDAV_URL est obsolète : utiliser CALDAV_ENDPOINT__URL.",
DeprecationWarning,
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["url"]}
return migrated_data
@model_validator(mode="after")
def _validate_endpoint_policy(self) -> CalDAVSettings:
"""Applique la politique HTTPS/HTTP loopback propre à CalDAV.
HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de HTTPS est toujours accepté. HTTP n'est accepté que pour un hôte de
boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement boucle locale (``localhost``, ``127.0.0.1``, ``::1``) et uniquement
lorsque ``allow_insecure_http`` vaut ``True``. Les messages d'erreur lorsque ``allow_insecure_http`` vaut ``True``. Les autres schémas du
ne contiennent jamais l'URL brute (susceptible de contenir des socle commun sont refusés pour ce connecteur.
identifiants).
:param v: Valeur du champ ``url`` à valider. :return: Instance validée inchangée.
:param info: Contexte de validation (accès aux autres champs). :rtype: CalDAVSettings
:return: La valeur validée inchangée.
:rtype: SecretStr | None
:raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP :raises ValueError: Si le schéma n'est pas supporté ou si l'URL HTTP
n'est pas autorisée. n'est pas autorisée.
""" """
if v is None: if self.endpoint is None:
return v return self
raw_url = v.get_secret_value() raw_url = self.endpoint.url.get_secret_value()
parsed = urlparse(raw_url) parsed = urlparse(raw_url)
if parsed.scheme not in ("http", "https"): if parsed.scheme not in ("http", "https"):
raise ValueError("URL CalDAV invalide : schéma non supporté") from None raise ValueError("URL CalDAV invalide : schéma non supporté") from None
if parsed.scheme == "https": if parsed.scheme == "https":
return v return self
# HTTP — check allow_insecure_http flag and loopback # HTTP — check allow_insecure_http flag and loopback
allow_insecure = info.data.get("allow_insecure_http", False) if not self.allow_insecure_http:
if not allow_insecure:
raise ValueError( raise ValueError(
"URL CalDAV non sécurisée : HTTPS requis (ou activer " "URL CalDAV non sécurisée : HTTPS requis (ou activer "
"CALDAV_ALLOW_INSECURE_HTTP pour localhost)" "CALDAV_ALLOW_INSECURE_HTTP pour localhost)"
) from None ) from None
hostname = parsed.hostname or "" hostname = parsed.hostname or ""
loopback_hosts = {"localhost", "127.0.0.1", "::1"} if hostname not in _LOOPBACK_HOSTS:
if hostname not in loopback_hosts:
raise ValueError( raise ValueError(
"URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost" "URL CalDAV non sécurisée : HTTP autorisé uniquement pour localhost"
) from None ) from None
return v return self
_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"}) _XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
@@ -342,7 +412,7 @@ class Settings(BaseSettings):
self.pronote.password, self.pronote.password,
self.pronote.qr_pin, self.pronote.qr_pin,
self.pronote.account_pin, self.pronote.account_pin,
self.caldav.url, self.caldav.endpoint.url if self.caldav.endpoint is not None else None,
self.caldav.password, self.caldav.password,
self.xmpp.password, self.xmpp.password,
self.ai.api_key, self.ai.api_key,
+3 -2
View File
@@ -79,11 +79,12 @@ class CalDAVGateway:
else cast(Callable[..., Any], caldav.DAVClient) else cast(Callable[..., Any], caldav.DAVClient)
) )
self._calendar_path: str = settings.calendar_path self._calendar_path: str = settings.calendar_path
url_secret = settings.endpoint.url if settings.endpoint is not None else None
self._redacted_url: str | None = ( self._redacted_url: str | None = (
redact_url(settings.url.get_secret_value()) if settings.url else None redact_url(url_secret.get_secret_value()) if url_secret else None
) )
self._username: str | None = settings.username self._username: str | None = settings.username
self._url_secret: SecretStr | None = settings.url self._url_secret: SecretStr | None = url_secret
self._password_secret: SecretStr | None = settings.password self._password_secret: SecretStr | None = settings.password
self._client: Any = None self._client: Any = None
self._calendar: Any = None self._calendar: Any = None
+1 -1
View File
@@ -96,7 +96,7 @@ def synchronize(
) )
if ( if (
settings.caldav.url is None settings.caldav.endpoint is None
or settings.caldav.username is None or settings.caldav.username is None
or settings.caldav.password is None or settings.caldav.password is None
): ):