Add application logging, SMTP email notifications, and supporting changes
Phase 5.4 — Application Logging: - AppLog model + migration 0010 for unified audit/usage/error logging - Usage logging middleware in app.py (after_request for /api/* requests) - Error logging in 500 handler with traceback capture - Audit logging for auth events (register, login, login_failed, logout, password_change) and admin actions (backup, restore, user_delete, registration_toggle, smtp_config, smtp_test) - Admin log viewer (LogsView.vue) with stats, category/search/date filters, paginated table with expandable detail rows - Admin logs API endpoints in admin.py (GET /logs, GET /logs/stats) - Configurable retention via LOG_RETENTION_DAYS with hourly cleanup Phase 5.5 — SMTP Email Notifications: - aiosmtplib dependency for async email sending - Email service (services/email.py) with STARTTLS/implicit TLS support - Notification service (services/notifications.py) for security alerts and task due date reminders with per-user preferences - Admin SMTP config endpoints (GET/PUT /api/admin/smtp, POST test) - SMTP config in Config class with env var + Docker secret support - Settings UI: notification preferences for all users, SMTP config section for admin with test email Other changes: - stream_chat() now accepts optional options dict (for num_predict) - Increase assist MAX_BODY_CHARS from 3000 to 8000 - get_user_by_username() added to auth service - apiStreamPost buffer processing refactored for robustness - AppHeader: admin Logs nav link - Router: /admin/logs route Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
import traceback as tb_module
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
||||
@@ -59,6 +60,24 @@ def create_app() -> Quart:
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
)
|
||||
|
||||
# Log usage for API requests (skip logs endpoint to avoid recursion)
|
||||
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
|
||||
try:
|
||||
from fabledassistant.services.logging import log_usage
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
await log_usage(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
endpoint=request.path,
|
||||
method=request.method,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to log usage", exc_info=True)
|
||||
|
||||
return response
|
||||
|
||||
@app.before_serving
|
||||
@@ -67,8 +86,12 @@ def create_app() -> Quart:
|
||||
|
||||
from fabledassistant.services.generation_buffer import start_cleanup_loop
|
||||
from fabledassistant.services.llm import ensure_model
|
||||
from fabledassistant.services.logging import start_log_retention_loop
|
||||
from fabledassistant.services.notifications import start_notification_loop
|
||||
|
||||
start_cleanup_loop()
|
||||
start_log_retention_loop()
|
||||
start_notification_loop()
|
||||
|
||||
async def _pull_model():
|
||||
try:
|
||||
@@ -111,6 +134,23 @@ def create_app() -> Quart:
|
||||
@app.errorhandler(500)
|
||||
async def handle_500(error):
|
||||
logger.exception("Internal server error on %s %s", request.method, request.path)
|
||||
|
||||
try:
|
||||
from fabledassistant.services.logging import log_error
|
||||
|
||||
user = getattr(g, "user", None)
|
||||
await log_error(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
endpoint=request.path,
|
||||
method=request.method,
|
||||
error_type=type(error).__name__,
|
||||
error_message=str(error),
|
||||
traceback=tb_module.format_exc(),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to log error", exc_info=True)
|
||||
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
return "Internal Server Error", 500
|
||||
|
||||
@@ -27,3 +27,13 @@ class Config:
|
||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||
LOG_RETENTION_DAYS: int = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
|
||||
|
||||
# SMTP defaults (overridden by DB settings when configured via admin UI)
|
||||
SMTP_HOST: str = os.environ.get("SMTP_HOST", "")
|
||||
SMTP_PORT: int = int(os.environ.get("SMTP_PORT", "587"))
|
||||
SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
|
||||
SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
|
||||
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
|
||||
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
|
||||
|
||||
@@ -15,3 +15,4 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa:
|
||||
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
|
||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
||||
from fabledassistant.models.user import User # noqa: E402, F401
|
||||
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
|
||||
|
||||
class AppLog(Base):
|
||||
__tablename__ = "app_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
category: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
username: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
action: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
method: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
details: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_app_logs_category", "category"),
|
||||
Index("ix_app_logs_user_id", "user_id"),
|
||||
Index("ix_app_logs_created_at", "created_at"),
|
||||
Index("ix_app_logs_category_created_at", "category", created_at.desc()),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"category": self.category,
|
||||
"user_id": self.user_id,
|
||||
"username": self.username,
|
||||
"action": self.action,
|
||||
"endpoint": self.endpoint,
|
||||
"method": self.method,
|
||||
"status_code": self.status_code,
|
||||
"duration_ms": self.duration_ms,
|
||||
"ip_address": self.ip_address,
|
||||
"details": self.details,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
from quart import Blueprint, Response, g, jsonify, request
|
||||
|
||||
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
||||
from fabledassistant.services.auth import (
|
||||
@@ -14,6 +14,9 @@ from fabledassistant.services.backup import (
|
||||
export_user_backup,
|
||||
restore_full_backup,
|
||||
)
|
||||
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_smtp_config, send_test_email
|
||||
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
|
||||
from fabledassistant.services.settings import set_settings_batch
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||
|
||||
@@ -26,13 +29,13 @@ async def backup():
|
||||
|
||||
if scope == "full":
|
||||
# Full backup requires admin
|
||||
from quart import g
|
||||
if g.user.role != "admin":
|
||||
return jsonify({"error": "Admin access required for full backup"}), 403
|
||||
data = await export_full_backup()
|
||||
else:
|
||||
data = await export_user_backup(uid)
|
||||
|
||||
await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
|
||||
return Response(
|
||||
json.dumps(data, indent=2, default=str),
|
||||
content_type="application/json",
|
||||
@@ -50,6 +53,8 @@ async def restore():
|
||||
return jsonify({"error": "No backup data provided"}), 400
|
||||
|
||||
stats = await restore_full_backup(data)
|
||||
uid = get_current_user_id()
|
||||
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
|
||||
return jsonify({"status": "ok", "stats": stats})
|
||||
|
||||
|
||||
@@ -70,6 +75,7 @@ async def remove_user(user_id: int):
|
||||
deleted = await delete_user(user_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@@ -90,4 +96,82 @@ async def toggle_registration():
|
||||
|
||||
uid = get_current_user_id()
|
||||
await set_registration_open(uid, bool(open_val))
|
||||
await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
|
||||
return jsonify({"status": "ok", "open": bool(open_val)})
|
||||
|
||||
|
||||
@admin_bp.route("/smtp", methods=["GET"])
|
||||
@admin_required
|
||||
async def get_smtp():
|
||||
config = await get_smtp_config()
|
||||
# Mask password
|
||||
if config.get("smtp_password"):
|
||||
config["smtp_password"] = "********"
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@admin_bp.route("/smtp", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_smtp():
|
||||
data = await request.get_json()
|
||||
uid = get_current_user_id()
|
||||
|
||||
settings_to_save = {}
|
||||
for key in SMTP_SETTING_KEYS:
|
||||
if key in data:
|
||||
# Skip password if it's the mask placeholder
|
||||
if key == "smtp_password" and data[key] == "********":
|
||||
continue
|
||||
settings_to_save[key] = str(data[key])
|
||||
|
||||
if settings_to_save:
|
||||
await set_settings_batch(uid, settings_to_save)
|
||||
await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@admin_bp.route("/smtp/test", methods=["POST"])
|
||||
@admin_required
|
||||
async def test_smtp():
|
||||
data = await request.get_json()
|
||||
recipient = (data.get("recipient") or "").strip()
|
||||
if not recipient:
|
||||
return jsonify({"error": "Recipient email is required"}), 400
|
||||
|
||||
uid = get_current_user_id()
|
||||
try:
|
||||
await send_test_email(recipient)
|
||||
await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route("/logs", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_logs():
|
||||
category = request.args.get("category")
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
search = request.args.get("search")
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
logs, total = await get_logs(
|
||||
category=category,
|
||||
user_id=user_id,
|
||||
search=search,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=min(limit, 200),
|
||||
offset=offset,
|
||||
)
|
||||
return jsonify({"logs": logs, "total": total})
|
||||
|
||||
|
||||
@admin_bp.route("/logs/stats", methods=["GET"])
|
||||
@admin_required
|
||||
async def log_stats():
|
||||
stats = await get_log_stats()
|
||||
return jsonify(stats)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from quart import Blueprint, jsonify, request, session
|
||||
import asyncio
|
||||
|
||||
from quart import Blueprint, g, jsonify, request, session
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.services.auth import (
|
||||
@@ -6,9 +8,12 @@ from fabledassistant.services.auth import (
|
||||
change_password,
|
||||
create_user,
|
||||
get_user_by_id,
|
||||
get_user_by_username,
|
||||
get_user_count,
|
||||
is_registration_open,
|
||||
)
|
||||
from fabledassistant.services.logging import log_audit
|
||||
from fabledassistant.services.notifications import notify_security_event
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
@@ -34,6 +39,7 @@ async def register():
|
||||
return jsonify({"error": "Username already taken"}), 409
|
||||
|
||||
session["user_id"] = user.id
|
||||
await log_audit("register", user_id=user.id, username=user.username, ip_address=request.remote_addr)
|
||||
return jsonify(user.to_dict()), 201
|
||||
|
||||
|
||||
@@ -48,14 +54,35 @@ async def login():
|
||||
|
||||
user = await authenticate(username, password)
|
||||
if not user:
|
||||
await log_audit("login_failed", username=username, ip_address=request.remote_addr)
|
||||
# Try to notify the actual user about the failed attempt
|
||||
target_user = await get_user_by_username(username)
|
||||
if target_user:
|
||||
asyncio.create_task(notify_security_event(
|
||||
target_user.id, "login_failed",
|
||||
{"ip_address": request.remote_addr, "username": username},
|
||||
))
|
||||
return jsonify({"error": "Invalid username or password"}), 401
|
||||
|
||||
session["user_id"] = user.id
|
||||
await log_audit("login", user_id=user.id, username=user.username, ip_address=request.remote_addr)
|
||||
asyncio.create_task(notify_security_event(
|
||||
user.id, "login",
|
||||
{"ip_address": request.remote_addr},
|
||||
))
|
||||
return jsonify(user.to_dict())
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
async def logout():
|
||||
uid = session.get("user_id")
|
||||
if uid:
|
||||
user = await get_user_by_id(uid)
|
||||
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=request.remote_addr)
|
||||
asyncio.create_task(notify_security_event(
|
||||
uid, "logout",
|
||||
{"ip_address": request.remote_addr},
|
||||
))
|
||||
session.clear()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@@ -86,6 +113,11 @@ async def update_password():
|
||||
if not success:
|
||||
return jsonify({"error": "Current password is incorrect"}), 403
|
||||
|
||||
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
|
||||
asyncio.create_task(notify_security_event(
|
||||
uid, "password_change",
|
||||
{"ip_address": request.remote_addr},
|
||||
))
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MAX_BODY_CHARS = 3000
|
||||
MAX_BODY_CHARS = 8000
|
||||
|
||||
|
||||
def build_assist_messages(
|
||||
|
||||
@@ -87,6 +87,14 @@ async def get_user_by_id(user_id: int) -> User | None:
|
||||
return await session.get(User, user_id)
|
||||
|
||||
|
||||
async def get_user_by_username(username: str) -> User | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
|
||||
"""Change a user's password. Returns True on success, False if current password is wrong."""
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Email service for sending notifications via SMTP."""
|
||||
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SMTP_SETTING_KEYS = [
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_username",
|
||||
"smtp_password",
|
||||
"smtp_from_address",
|
||||
"smtp_from_name",
|
||||
"smtp_use_tls",
|
||||
]
|
||||
|
||||
|
||||
async def get_smtp_config() -> dict[str, str]:
|
||||
"""Get SMTP config from admin's settings, falling back to env vars."""
|
||||
config: dict[str, str] = {
|
||||
"smtp_host": Config.SMTP_HOST,
|
||||
"smtp_port": str(Config.SMTP_PORT),
|
||||
"smtp_username": Config.SMTP_USERNAME,
|
||||
"smtp_password": Config.SMTP_PASSWORD,
|
||||
"smtp_from_address": Config.SMTP_FROM_ADDRESS,
|
||||
"smtp_from_name": Config.SMTP_FROM_NAME,
|
||||
"smtp_use_tls": "true" if Config.SMTP_USE_TLS else "false",
|
||||
}
|
||||
|
||||
# Override with DB settings from admin user
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting)
|
||||
.join(User, Setting.user_id == User.id)
|
||||
.where(User.role == "admin", Setting.key.in_(SMTP_SETTING_KEYS))
|
||||
)
|
||||
for setting in result.scalars().all():
|
||||
config[setting.key] = setting.value
|
||||
|
||||
return config
|
||||
|
||||
|
||||
async def is_smtp_configured() -> bool:
|
||||
"""Check if SMTP is configured (has a host set)."""
|
||||
config = await get_smtp_config()
|
||||
return bool(config.get("smtp_host"))
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, html_body: str) -> None:
|
||||
"""Send an email via SMTP."""
|
||||
config = await get_smtp_config()
|
||||
host = config.get("smtp_host")
|
||||
if not host:
|
||||
logger.debug("SMTP not configured, skipping email to %s", to)
|
||||
return
|
||||
|
||||
port = int(config.get("smtp_port", "587"))
|
||||
username = config.get("smtp_username", "")
|
||||
password = config.get("smtp_password", "")
|
||||
from_address = config.get("smtp_from_address", "")
|
||||
from_name = config.get("smtp_from_name", "Fabled Assistant")
|
||||
use_tls = config.get("smtp_use_tls", "true") == "true"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{from_name} <{from_address}>" if from_name else from_address
|
||||
msg["To"] = to
|
||||
msg.set_content(subject) # plain text fallback
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=host,
|
||||
port=port,
|
||||
username=username or None,
|
||||
password=password or None,
|
||||
start_tls=use_tls and port != 465,
|
||||
use_tls=port == 465,
|
||||
)
|
||||
logger.info("Email sent to %s: %s", to, subject)
|
||||
except Exception:
|
||||
logger.exception("Failed to send email to %s: %s", to, subject)
|
||||
raise
|
||||
|
||||
|
||||
async def send_test_email(to: str) -> None:
|
||||
"""Send a branded test email."""
|
||||
html = """
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Fabled Assistant</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 12px; color: #374151;">Your SMTP configuration is working correctly.</p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px;">This is a test email sent from your Fabled Assistant instance.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(to, "Fabled Assistant - Test Email", html)
|
||||
@@ -72,14 +72,19 @@ async def ensure_model(model: str) -> None:
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
messages: list[dict], model: str
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
options: dict | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completion from Ollama, yielding content chunks."""
|
||||
payload: dict = {"model": model, "messages": messages, "stream": True}
|
||||
if options:
|
||||
payload["options"] = options
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{Config.OLLAMA_URL}/api/chat",
|
||||
json={"model": model, "messages": messages, "stream": True},
|
||||
json=payload,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Application logging service for audit, usage, and error events."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback as tb_module
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select, text
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.app_log import AppLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_retention_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def log_audit(
|
||||
action: str,
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
details: dict | None = None,
|
||||
) -> None:
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="audit",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
ip_address=ip_address,
|
||||
details=json.dumps(details) if details else None,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def log_usage(
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
status_code: int | None = None,
|
||||
duration_ms: float | None = None,
|
||||
) -> None:
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="usage",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
status_code=status_code,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def log_error(
|
||||
user_id: int | None = None,
|
||||
username: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
method: str | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
traceback: str | None = None,
|
||||
) -> None:
|
||||
details = {}
|
||||
if error_type:
|
||||
details["error_type"] = error_type
|
||||
if error_message:
|
||||
details["error_message"] = error_message
|
||||
if traceback:
|
||||
details["traceback"] = traceback
|
||||
|
||||
async with async_session() as session:
|
||||
log = AppLog(
|
||||
category="error",
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
details=json.dumps(details) if details else None,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def get_logs(
|
||||
category: str | None = None,
|
||||
user_id: int | None = None,
|
||||
search: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
async with async_session() as session:
|
||||
query = select(AppLog)
|
||||
count_query = select(func.count(AppLog.id))
|
||||
|
||||
if category:
|
||||
query = query.where(AppLog.category == category)
|
||||
count_query = count_query.where(AppLog.category == category)
|
||||
if user_id is not None:
|
||||
query = query.where(AppLog.user_id == user_id)
|
||||
count_query = count_query.where(AppLog.user_id == user_id)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
search_filter = (
|
||||
AppLog.action.ilike(pattern)
|
||||
| AppLog.endpoint.ilike(pattern)
|
||||
| AppLog.username.ilike(pattern)
|
||||
| AppLog.details.ilike(pattern)
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
if date_from:
|
||||
query = query.where(AppLog.created_at >= date_from)
|
||||
count_query = count_query.where(AppLog.created_at >= date_from)
|
||||
if date_to:
|
||||
query = query.where(AppLog.created_at <= date_to)
|
||||
count_query = count_query.where(AppLog.created_at <= date_to)
|
||||
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = query.order_by(AppLog.created_at.desc()).limit(limit).offset(offset)
|
||||
result = await session.execute(query)
|
||||
logs = [row.to_dict() for row in result.scalars().all()]
|
||||
|
||||
return logs, total
|
||||
|
||||
|
||||
async def get_log_stats() -> dict:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT category, COUNT(*) as count FROM app_logs GROUP BY category"
|
||||
)
|
||||
)
|
||||
stats = {row.category: row.count for row in result}
|
||||
return {
|
||||
"audit": stats.get("audit", 0),
|
||||
"usage": stats.get("usage", 0),
|
||||
"error": stats.get("error", 0),
|
||||
"total": sum(stats.values()),
|
||||
}
|
||||
|
||||
|
||||
async def delete_old_logs(retention_days: int) -> int:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
delete(AppLog).where(AppLog.created_at < cutoff)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def _retention_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
try:
|
||||
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
|
||||
if deleted:
|
||||
logger.info("Log retention: deleted %d old log entries", deleted)
|
||||
except Exception:
|
||||
logger.exception("Error in log retention cleanup")
|
||||
|
||||
|
||||
def start_log_retention_loop() -> None:
|
||||
global _retention_task
|
||||
if _retention_task is None or _retention_task.done():
|
||||
_retention_task = asyncio.create_task(_retention_loop())
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Notification service for security alerts and task reminders."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.app_log import AppLog
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.setting import Setting
|
||||
from fabledassistant.models.user import User
|
||||
from fabledassistant.services.email import is_smtp_configured, send_email
|
||||
from fabledassistant.services.logging import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_notification_task: asyncio.Task | None = None
|
||||
|
||||
SECURITY_EVENT_LABELS = {
|
||||
"login": "New Login",
|
||||
"login_failed": "Failed Login Attempt",
|
||||
"logout": "Logout",
|
||||
"password_change": "Password Changed",
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_notification_pref(user_id: int, key: str) -> bool:
|
||||
"""Check if a user has a notification preference enabled (default True)."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
# Default to enabled
|
||||
return setting.value != "false" if setting else True
|
||||
|
||||
|
||||
async def _get_user_email(user_id: int) -> str | None:
|
||||
"""Get a user's email address."""
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
return user.email if user else None
|
||||
|
||||
|
||||
async def notify_security_event(
|
||||
user_id: int, event_type: str, details: dict | None = None
|
||||
) -> None:
|
||||
"""Send a security alert email to a user if they have alerts enabled and an email set."""
|
||||
try:
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
|
||||
if not await _get_user_notification_pref(user_id, "notify_security_alerts"):
|
||||
return
|
||||
|
||||
email = await _get_user_email(user_id)
|
||||
if not email:
|
||||
return
|
||||
|
||||
label = SECURITY_EVENT_LABELS.get(event_type, event_type)
|
||||
ip = details.get("ip_address", "Unknown") if details else "Unknown"
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
detail_rows = ""
|
||||
if details:
|
||||
for k, v in details.items():
|
||||
if k == "ip_address":
|
||||
continue
|
||||
detail_rows += f'<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">{k}</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{v}</td></tr>'
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Security Alert</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151; font-size: 16px; font-weight: 600;">{label}</p>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">Time</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{timestamp}</td></tr>
|
||||
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">IP Address</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{ip}</td></tr>
|
||||
{detail_rows}
|
||||
</table>
|
||||
<p style="margin: 16px 0 0; color: #9ca3af; font-size: 12px;">If this wasn't you, change your password immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, f"Fabled Assistant - {label}", html)
|
||||
except Exception:
|
||||
logger.exception("Failed to send security notification for user %d", user_id)
|
||||
|
||||
|
||||
async def check_due_tasks() -> None:
|
||||
"""Check for tasks due today and send reminder emails."""
|
||||
if not await is_smtp_configured():
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
today_str = today.isoformat()
|
||||
|
||||
async with async_session() as session:
|
||||
# Find tasks due today or overdue, not done
|
||||
result = await session.execute(
|
||||
select(Note)
|
||||
.where(
|
||||
Note.status.isnot(None),
|
||||
Note.status != "done",
|
||||
Note.due_date <= today,
|
||||
)
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
if not tasks:
|
||||
return
|
||||
|
||||
# Group by user
|
||||
tasks_by_user: dict[int, list[Note]] = {}
|
||||
for task in tasks:
|
||||
if task.user_id:
|
||||
tasks_by_user.setdefault(task.user_id, []).append(task)
|
||||
|
||||
for user_id, user_tasks in tasks_by_user.items():
|
||||
try:
|
||||
# Check notification pref
|
||||
if not await _get_user_notification_pref(user_id, "notify_task_reminders"):
|
||||
continue
|
||||
|
||||
email = await _get_user_email(user_id)
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Dedup: check if we already sent a reminder today
|
||||
dedup_result = await session.execute(
|
||||
select(func.count(AppLog.id)).where(
|
||||
AppLog.action == "task_reminder",
|
||||
AppLog.user_id == user_id,
|
||||
AppLog.created_at >= today_str,
|
||||
)
|
||||
)
|
||||
if (dedup_result.scalar() or 0) > 0:
|
||||
continue
|
||||
|
||||
# Build task list HTML
|
||||
task_rows = ""
|
||||
for task in user_tasks:
|
||||
overdue = task.due_date < today if task.due_date else False
|
||||
date_color = "#ef4444" if overdue else "#374151"
|
||||
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else ""
|
||||
overdue_badge = ' <span style="color: #ef4444; font-weight: 600; font-size: 12px;">(overdue)</span>' if overdue else ""
|
||||
task_rows += f"""
|
||||
<tr>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; font-size: 14px;">{task.title}</td>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-size: 14px;">{date_label}{overdue_badge}</td>
|
||||
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">{task.status}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px;">Task Reminders</h1>
|
||||
</div>
|
||||
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin: 0 0 16px; color: #374151;">You have {len(user_tasks)} task(s) due:</p>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Task</th>
|
||||
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Due</th>
|
||||
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Status</th>
|
||||
</tr>
|
||||
{task_rows}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", html)
|
||||
await log_audit(
|
||||
"task_reminder",
|
||||
user_id=user_id,
|
||||
details={"task_count": len(user_tasks), "task_ids": [t.id for t in user_tasks]},
|
||||
)
|
||||
logger.info("Sent task reminder to user %d (%d tasks)", user_id, len(user_tasks))
|
||||
except Exception:
|
||||
logger.exception("Failed to send task reminder for user %d", user_id)
|
||||
|
||||
|
||||
async def _notification_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600) # hourly
|
||||
try:
|
||||
await check_due_tasks()
|
||||
except Exception:
|
||||
logger.exception("Error in notification loop")
|
||||
|
||||
|
||||
def start_notification_loop() -> None:
|
||||
global _notification_task
|
||||
if _notification_task is None or _notification_task.done():
|
||||
_notification_task = asyncio.create_task(_notification_loop())
|
||||
Reference in New Issue
Block a user