feat: intégration TikTak #197-200 + scoped tokens, webhooks filtrés, listes mtime/size ; fix e2e-server BUG-112
This commit is contained in:
+89
-3
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user