feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
CI / lint (push) Successful in 3m17s
CI / security (push) Failing after 1m51s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m50s
CI / e2e (push) Successful in 18m18s

This commit is contained in:
2026-10-14 11:42:53 -04:00
parent 2be0b15bcf
commit f783394f09
59 changed files with 3233 additions and 77 deletions
+89 -3
View File
@@ -16,6 +16,9 @@ Security (BUG-026):
Events: file_created, file_deleted, file_modified, file_renamed,
directory_created, directory_deleted, directory_renamed
(#198 : chaque webhook peut porter un filtre optionnel ``vault`` et/ou
``path_prefix`` — seuls les événements correspondants sont livrés ; la
charge utile inclut toujours ``vault`` et le(s) chemin(s)).
"""
import asyncio
@@ -120,6 +123,65 @@ def is_safe_target(url: str) -> bool:
return True
def _normalize_vault_filter(vault: str | None) -> str | None:
"""Normalize an optional vault filter (``None``/empty = no filtering)."""
if not vault or not isinstance(vault, str):
return None
cleaned = vault.strip()
return cleaned or None
def _normalize_prefix_filter(prefix: str | None) -> str | None:
"""Normalize an optional path-prefix filter (``None``/empty = no filtering)."""
if not prefix or not isinstance(prefix, str):
return None
cleaned = prefix.strip().strip("/")
return cleaned or None
def _event_paths(data: dict) -> list[str]:
"""Vault-relative paths carried by a webhook payload.
File/directory events use ``path``; renames and moves carry
``old_path``/``new_path`` instead.
"""
paths = []
for key in ("path", "old_path", "new_path"):
value = data.get(key)
if isinstance(value, str) and value:
paths.append(value.strip("/"))
return paths
def webhook_matches(wh: dict, event_type: str, data: dict) -> bool:
"""True when webhook *wh* must receive (*event_type*, *data*).
Pure function (no I/O) so the filtering is unit-testable :
- event subscription (as before) ;
- #198 optional ``vault`` filter — exact match on ``data["vault"]`` ;
- #198 optional ``path_prefix`` filter — matches when any carried path
equals the prefix or lies underneath it (``Docs`` matches ``Docs`` and
``Docs/a.md``, not ``Docs2/b.md``).
Webhooks created before #198 carry neither key and keep receiving
everything they subscribe to (backward compatible).
"""
if event_type not in wh.get("events", []):
return False
vault_filter = _normalize_vault_filter(wh.get("vault"))
if vault_filter and data.get("vault") != vault_filter:
return False
prefix = _normalize_prefix_filter(wh.get("path_prefix"))
if prefix:
wanted = prefix + "/"
for candidate in _event_paths(data):
if candidate == prefix or candidate.startswith(wanted):
break
else:
return False
return True
def _read() -> list:
if not WEBHOOKS_FILE.exists():
return []
@@ -195,7 +257,14 @@ def get_webhooks() -> list:
return [_public_view(wh) for wh in _read()]
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
def create_webhook(
name: str,
url: str,
events: list[str],
secret: str | None = None,
vault: str | None = None,
path_prefix: str | None = None,
) -> dict:
validate_webhook_url(url)
with _lock:
webhooks = _read()
@@ -208,6 +277,9 @@ def create_webhook(name: str, url: str, events: list[str], secret: str | None =
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault),
"path_prefix": _normalize_prefix_filter(path_prefix),
}
webhooks.append(wh)
_write(webhooks)
@@ -226,6 +298,13 @@ def update_webhook(wh_id: str, updates: dict) -> dict | None:
validate_webhook_url(updates["url"])
if "secret" in updates:
_store_secret(wh_id, updates["secret"])
# #198 : normaliser les filtres ("" réinitialise le filtre).
for key, normalizer in (
("vault", _normalize_vault_filter),
("path_prefix", _normalize_prefix_filter),
):
if key in updates:
updates[key] = normalizer(updates[key])
safe_updates = {
k: v for k, v in updates.items()
if k not in ("id", "secret")
@@ -250,9 +329,16 @@ def delete_webhook(wh_id: str) -> bool:
async def dispatch_webhooks(event_type: str, data: dict):
"""Fire all enabled webhooks subscribed to event_type."""
"""Fire all enabled webhooks subscribed to event_type.
#198 : les webhooks portant un filtre ``vault``/``path_prefix`` ne sont
livrés que pour les événements correspondants (voir :func:`webhook_matches`).
"""
webhooks = _read()
targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])]
targets = [
wh for wh in webhooks
if wh.get("enabled", True) and webhook_matches(wh, event_type, data)
]
if not targets:
return