mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Fix notification System
This commit is contained in:
@@ -1,20 +1,24 @@
|
||||
"""
|
||||
Servizio Notifiche Multi-canale – CRUD canali, regole, log.
|
||||
Servizio Notifiche Multi-canale – CRUD canali, regole, log + dispatch.
|
||||
|
||||
Nota: la cifratura AES-256-GCM di config_enc avviene qui usando
|
||||
la NOTIFICATION_SECRET_KEY dalla config. Per semplicità in questo
|
||||
stub usiamo Fernet (libreria cryptography), facilmente sostituibile
|
||||
con una implementazione GCM dedicata.
|
||||
Cifratura: AES-256-GCM via libreria cryptography.
|
||||
Formato config_enc: base64( nonce(12) || ciphertext+tag )
|
||||
Chiave: ENCRYPTION_KEY (hex 64 char = 32 byte) dalla config.
|
||||
|
||||
Backward compatibility: se il valore non decrittografa come GCM, viene
|
||||
tentato il fallback a base64 grezzo (configurazioni precedenti al fix).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.exceptions import NotFoundError
|
||||
@@ -27,20 +31,53 @@ from app.schemas.notification import (
|
||||
NotificationRuleUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _encrypt(data: dict) -> str:
|
||||
"""Cifra un dict JSON → base64. Usa la SECRET_KEY come seed."""
|
||||
# In produzione: usa AES-256-GCM. Qui: semplice base64 con marker.
|
||||
raw = json.dumps(data).encode()
|
||||
return base64.b64encode(raw).decode()
|
||||
# ─── Cifratura AES-256-GCM ────────────────────────────────────────────────────
|
||||
|
||||
def _encrypt(data: dict, key: bytes | None = None) -> str:
|
||||
"""
|
||||
Cifra un dict JSON con AES-256-GCM.
|
||||
|
||||
Formato output: base64( nonce(12 byte) || ciphertext+tag(16 byte) )
|
||||
"""
|
||||
if key is None:
|
||||
key = settings.encryption_key_bytes
|
||||
nonce = os.urandom(12)
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext, None) # include tag
|
||||
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
||||
|
||||
|
||||
def _decrypt(enc: str) -> dict:
|
||||
"""Decifra il valore restituito da _encrypt."""
|
||||
raw = base64.b64decode(enc.encode())
|
||||
return json.loads(raw.decode())
|
||||
def _decrypt(enc: str, key: bytes | None = None) -> dict:
|
||||
"""
|
||||
Decifra il valore prodotto da _encrypt.
|
||||
|
||||
Backward compatible: se il dato non e' GCM valido, prova il
|
||||
vecchio base64 grezzo (usato prima del fix di sicurezza).
|
||||
"""
|
||||
if key is None:
|
||||
key = settings.encryption_key_bytes
|
||||
try:
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
if len(raw) > 28: # 12 nonce + 16 tag minimo
|
||||
nonce = raw[:12]
|
||||
ciphertext = raw[12:]
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||
return json.loads(plaintext.decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: base64 grezzo (configurazioni create prima del fix GCM)
|
||||
try:
|
||||
raw = base64.b64decode(enc.encode("ascii"))
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -113,6 +150,7 @@ class NotificationService:
|
||||
if data.config is not None:
|
||||
channel.config = data.config
|
||||
if data.config_secret is not None:
|
||||
# Re-cifra sempre con AES-256-GCM (aggiorna anche i vecchi base64)
|
||||
channel.config_enc = _encrypt(data.config_secret)
|
||||
|
||||
await self.db.flush()
|
||||
@@ -128,18 +166,16 @@ class NotificationService:
|
||||
self, channel_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> ChannelTestResult:
|
||||
"""
|
||||
Invia un messaggio di test al canale configurato.
|
||||
Invia un messaggio di test reale al canale configurato.
|
||||
|
||||
Questa implementazione stub restituisce sempre successo se il canale
|
||||
è attivo e configurato. Una implementazione completa fa una chiamata
|
||||
reale al canale (HTTP/SMTP/Telegram/WhatsApp).
|
||||
Esegue invio effettivo per Telegram, Webhook, Email SMTP e WhatsApp.
|
||||
"""
|
||||
channel = await self.get_channel(channel_id, tenant_id)
|
||||
|
||||
if not channel.is_active:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message="Il canale è disabilitato",
|
||||
message="Il canale e' disabilitato",
|
||||
)
|
||||
|
||||
if channel.circuit_open_until and channel.circuit_open_until > datetime.now(timezone.utc):
|
||||
@@ -148,21 +184,23 @@ class NotificationService:
|
||||
message=f"Circuit breaker aperto fino a {channel.circuit_open_until.isoformat()}",
|
||||
)
|
||||
|
||||
# Validazione configurazione minima per tipo canale
|
||||
config = channel.config or {}
|
||||
secret = {}
|
||||
if channel.config_enc:
|
||||
try:
|
||||
secret = _decrypt(channel.config_enc)
|
||||
except Exception as e:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore decifratura configurazione sensibile: {e}",
|
||||
)
|
||||
|
||||
channel_type = channel.channel_type
|
||||
|
||||
if channel_type == "webhook":
|
||||
if not config.get("url"):
|
||||
return ChannelTestResult(success=False, message="URL webhook non configurato")
|
||||
elif channel_type == "email":
|
||||
if not config.get("to_email"):
|
||||
return ChannelTestResult(success=False, message="Email destinatario non configurata")
|
||||
elif channel_type == "telegram":
|
||||
# ── Telegram ──────────────────────────────────────────────────────────
|
||||
if channel_type == "telegram":
|
||||
if not config.get("chat_id"):
|
||||
return ChannelTestResult(success=False, message="Chat ID Telegram non configurato")
|
||||
# Invio reale via Bot API
|
||||
secret = _decrypt(channel.config_enc) if channel.config_enc else {}
|
||||
bot_token = secret.get("bot_token")
|
||||
if not bot_token:
|
||||
return ChannelTestResult(success=False, message="Bot token Telegram non configurato")
|
||||
@@ -176,28 +214,128 @@ class NotificationService:
|
||||
msg_id = result.get("message_id")
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Messaggio Telegram inviato con successo (message_id={msg_id}).",
|
||||
message=f"Messaggio Telegram inviato (message_id={msg_id}).",
|
||||
http_status=200,
|
||||
)
|
||||
except TelegramError as exc:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore Telegram: {exc}",
|
||||
http_status=exc.http_status,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore imprevisto durante il test Telegram: {exc}",
|
||||
message=f"Errore Telegram: {exc}",
|
||||
)
|
||||
elif channel_type == "whatsapp":
|
||||
if not config.get("phone_number"):
|
||||
return ChannelTestResult(success=False, message="Numero WhatsApp non configurato")
|
||||
|
||||
# ── Webhook ───────────────────────────────────────────────────────────
|
||||
elif channel_type == "webhook":
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
return ChannelTestResult(success=False, message="URL webhook non configurato")
|
||||
webhook_secret = secret.get("webhook_secret")
|
||||
try:
|
||||
from app.notifications.webhook import WebhookError, send_test_webhook
|
||||
result = await send_test_webhook(
|
||||
url=url,
|
||||
webhook_secret=webhook_secret,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=(
|
||||
f"Webhook raggiunto con successo "
|
||||
f"(HTTP {result['http_status']}, delivery={result['delivery_id']})."
|
||||
),
|
||||
http_status=result["http_status"],
|
||||
)
|
||||
except Exception as exc:
|
||||
http_status = getattr(exc, "http_status", None)
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore webhook: {exc}",
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
# ── Email SMTP ────────────────────────────────────────────────────────
|
||||
elif channel_type == "email":
|
||||
smtp_host = config.get("smtp_host")
|
||||
smtp_port = config.get("smtp_port", 465)
|
||||
from_email = config.get("from_email")
|
||||
to_email = config.get("to_email")
|
||||
smtp_user = config.get("smtp_user") or from_email
|
||||
use_tls = config.get("smtp_use_tls", True)
|
||||
use_starttls = config.get("smtp_use_starttls", False)
|
||||
from_name = config.get("from_name", "PEChub Notifiche")
|
||||
smtp_password = secret.get("smtp_password", "")
|
||||
|
||||
if not smtp_host:
|
||||
return ChannelTestResult(success=False, message="Host SMTP non configurato")
|
||||
if not from_email:
|
||||
return ChannelTestResult(success=False, message="Email mittente non configurata")
|
||||
if not to_email:
|
||||
return ChannelTestResult(success=False, message="Email destinatario non configurata")
|
||||
if not smtp_password:
|
||||
return ChannelTestResult(success=False, message="Password SMTP non configurata")
|
||||
|
||||
try:
|
||||
from app.notifications.email_smtp import EmailSMTPError, send_test_email
|
||||
await send_test_email(
|
||||
smtp_host=smtp_host,
|
||||
smtp_port=int(smtp_port),
|
||||
smtp_user=smtp_user,
|
||||
smtp_password=smtp_password,
|
||||
from_email=from_email,
|
||||
to_email=to_email,
|
||||
channel_name=channel.name,
|
||||
from_name=from_name,
|
||||
use_tls=use_tls,
|
||||
use_starttls=use_starttls,
|
||||
)
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Email di test inviata con successo a {to_email}.",
|
||||
http_status=200,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore email: {exc}",
|
||||
)
|
||||
|
||||
# ── WhatsApp ──────────────────────────────────────────────────────────
|
||||
elif channel_type == "whatsapp":
|
||||
phone_number_id = config.get("phone_number_id")
|
||||
to_phone = config.get("to_phone")
|
||||
access_token = secret.get("access_token")
|
||||
|
||||
if not phone_number_id:
|
||||
return ChannelTestResult(success=False, message="phone_number_id non configurato")
|
||||
if not to_phone:
|
||||
return ChannelTestResult(success=False, message="Numero WhatsApp destinatario non configurato")
|
||||
if not access_token:
|
||||
return ChannelTestResult(success=False, message="Access token Meta non configurato")
|
||||
|
||||
try:
|
||||
from app.notifications.whatsapp import WhatsAppError, send_test_whatsapp
|
||||
result = await send_test_whatsapp(
|
||||
phone_number_id=phone_number_id,
|
||||
to_phone=to_phone,
|
||||
access_token=access_token,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Messaggio WhatsApp inviato (message_id={result.get('message_id')}).",
|
||||
http_status=200,
|
||||
)
|
||||
except Exception as exc:
|
||||
http_status = getattr(exc, "http_status", None)
|
||||
return ChannelTestResult(
|
||||
success=False,
|
||||
message=f"Errore WhatsApp: {exc}",
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
# ── Tipo sconosciuto ──────────────────────────────────────────────────
|
||||
return ChannelTestResult(
|
||||
success=True,
|
||||
message=f"Canale {channel_type} configurato correttamente. Test simulato con successo.",
|
||||
http_status=200,
|
||||
success=False,
|
||||
message=f"Tipo canale '{channel_type}' non supportato",
|
||||
)
|
||||
|
||||
# ─── Rules ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user