diff --git a/alembic/versions/0010_add_app_logs_table.py b/alembic/versions/0010_add_app_logs_table.py new file mode 100644 index 0000000..4c2a10a --- /dev/null +++ b/alembic/versions/0010_add_app_logs_table.py @@ -0,0 +1,40 @@ +"""Add app_logs table for audit, usage, and error logging.""" + +from alembic import op + +revision = "0010" +down_revision = "0009" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS app_logs ( + id SERIAL PRIMARY KEY, + category TEXT NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + username TEXT, + action TEXT, + endpoint TEXT, + method TEXT, + status_code INTEGER, + duration_ms REAL, + ip_address TEXT, + details TEXT, + created_at TIMESTAMPTZ DEFAULT now() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_app_logs_category ON app_logs (category)") + op.execute("CREATE INDEX IF NOT EXISTS ix_app_logs_user_id ON app_logs (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_app_logs_created_at ON app_logs (created_at)") + op.execute( + "CREATE INDEX IF NOT EXISTS ix_app_logs_category_created_at " + "ON app_logs (category, created_at DESC)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_app_logs_category_created_at") + op.execute("DROP INDEX IF EXISTS ix_app_logs_created_at") + op.execute("DROP INDEX IF EXISTS ix_app_logs_user_id") + op.execute("DROP INDEX IF EXISTS ix_app_logs_category") + op.execute("DROP TABLE IF EXISTS app_logs") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2c655c0..68076c8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -226,22 +226,25 @@ export async function apiStreamPost( const decoder = new TextDecoder(); let buffer = ""; - function processLines(text: string) { - const lines = text.split("\n"); + function processLine(line: string) { + const trimmed = line.trim(); + if (trimmed.startsWith("data: ")) { + let data; + try { + data = JSON.parse(trimmed.slice(6)); + } catch { + return; // Skip malformed JSON lines + } + onChunk(data); + } + } + + function processBuffer() { + const lines = buffer.split("\n"); // Keep the last (possibly incomplete) line in the buffer buffer = lines.pop() || ""; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("data: ")) { - let data; - try { - data = JSON.parse(trimmed.slice(6)); - } catch { - continue; // Skip malformed JSON lines - } - onChunk(data); - } + processLine(line); } } @@ -250,22 +253,19 @@ export async function apiStreamPost( const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - processLines(buffer); + processBuffer(); } } catch { // Stream may close with a network error after all data was sent. - // Process whatever remains in the buffer before deciding to throw. } - // Process any remaining buffer - const remaining = buffer.trim(); - if (remaining.startsWith("data: ")) { - let data; - try { - data = JSON.parse(remaining.slice(6)); - } catch { - return; // Skip malformed JSON + // Flush any remaining complete lines in the buffer + if (buffer.trim()) { + // The buffer may contain one or more unprocessed lines + const remaining = buffer; + buffer = ""; + for (const line of remaining.split("\n")) { + processLine(line); } - onChunk(data); } } diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 1b534be..aead2ee 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -62,6 +62,7 @@ router.afterEach(() => { Chat Settings Users + Logs diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d85b5c3..1b895ed 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -82,6 +82,11 @@ const router = createRouter({ name: "admin-users", component: () => import("@/views/UserManagementView.vue"), }, + { + path: "/admin/logs", + name: "admin-logs", + component: () => import("@/views/LogsView.vue"), + }, ], }); diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue new file mode 100644 index 0000000..2f4bc70 --- /dev/null +++ b/frontend/src/views/LogsView.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 515ba97..eb88391 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,7 +3,7 @@ import { ref, computed, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiPut } from "@/api/client"; +import { apiGet, apiPost, apiPut } from "@/api/client"; import type { ModelInfo } from "@/types/settings"; const store = useSettingsStore(); @@ -23,6 +23,27 @@ const exporting = ref(false); const restoring = ref(false); const restoreFileInput = ref(null); +// Notification preferences +const notifyTaskReminders = ref(true); +const notifySecurityAlerts = ref(true); +const savingNotifications = ref(false); +const notificationsSaved = ref(false); + +// SMTP settings (admin only) +const smtp = ref({ + smtp_host: "", + smtp_port: "587", + smtp_username: "", + smtp_password: "", + smtp_from_address: "", + smtp_from_name: "Fabled Assistant", + smtp_use_tls: "true", +}); +const savingSmtp = ref(false); +const smtpSaved = ref(false); +const testRecipient = ref(""); +const sendingTest = ref(false); + const MODEL_CATALOG: ModelInfo[] = [ // — General Purpose — { @@ -193,6 +214,25 @@ onMounted(async () => { assistantName.value = store.assistantName; selectedModel.value = store.defaultModel; store.fetchInstalledModels(); + + // Load notification preferences from user settings + const allSettings = await apiGet>("/api/settings"); + if (allSettings.notify_task_reminders !== undefined) { + notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; + } + if (allSettings.notify_security_alerts !== undefined) { + notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false"; + } + + // Load SMTP config if admin + if (authStore.isAdmin) { + try { + const smtpConfig = await apiGet>("/api/admin/smtp"); + smtp.value = { ...smtp.value, ...smtpConfig }; + } catch { + // SMTP not configured yet + } + } }); async function changePassword() { @@ -319,6 +359,58 @@ function triggerRestoreUpload() { restoreFileInput.value?.click(); } +async function saveNotifications() { + savingNotifications.value = true; + notificationsSaved.value = false; + try { + await apiPut("/api/settings", { + notify_task_reminders: notifyTaskReminders.value ? "true" : "false", + notify_security_alerts: notifySecurityAlerts.value ? "true" : "false", + }); + notificationsSaved.value = true; + setTimeout(() => (notificationsSaved.value = false), 2000); + } catch { + toastStore.show("Failed to save notification preferences", "error"); + } finally { + savingNotifications.value = false; + } +} + +async function saveSmtp() { + savingSmtp.value = true; + smtpSaved.value = false; + try { + await apiPut("/api/admin/smtp", smtp.value); + smtpSaved.value = true; + setTimeout(() => (smtpSaved.value = false), 2000); + } catch { + toastStore.show("Failed to save SMTP settings", "error"); + } finally { + savingSmtp.value = false; + } +} + +async function sendTestEmail() { + if (!testRecipient.value.trim()) { + toastStore.show("Enter a recipient email address", "error"); + return; + } + sendingTest.value = true; + try { + await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() }); + toastStore.show("Test email sent successfully"); + } catch (e: unknown) { + if (e && typeof e === "object" && "body" in e) { + const body = (e as { body?: { error?: string } }).body; + toastStore.show(body?.error || "Failed to send test email", "error"); + } else { + toastStore.show("Failed to send test email", "error"); + } + } finally { + sendingTest.value = false; + } +} + async function handleRestoreFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -425,6 +517,99 @@ async function handleRestoreFile(event: Event) { +
+

Notifications

+

+ Choose which email notifications you'd like to receive. Requires SMTP to be configured by an admin. +

+ +
+ +

Receive a daily email when you have tasks due or overdue.

+
+
+ +

Receive emails for login, logout, failed login attempts, and password changes.

+
+
+ + Saved! +
+
+ +
+

Email / SMTP

+

+ Configure SMTP settings to enable email notifications for all users. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +

Enable STARTTLS encryption (recommended for port 587). Implicit TLS is used automatically for port 465.

+
+ +
+ + Saved! +
+ +
+

Test Email

+
+ + +
+
+
+

Model

@@ -843,4 +1028,51 @@ async function handleRestoreFile(event: Event) { .hidden-file-input { display: none; } + +/* Checkbox fields */ +.checkbox-field { + margin-bottom: 1rem; +} +.checkbox-field label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + color: var(--color-text); + cursor: pointer; +} +.checkbox-field input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--color-primary); +} + +/* SMTP grid */ +.smtp-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 1rem; +} +@media (max-width: 480px) { + .smtp-grid { + grid-template-columns: 1fr; + } +} + +/* Test email */ +.subsection-label { + margin: 0 0 0.5rem; + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text-secondary); +} +.test-email-section { + border-top: 1px solid var(--color-border); + padding-top: 1rem; +} +.test-email-row { + display: flex; + gap: 0.5rem; + align-items: center; +} diff --git a/pyproject.toml b/pyproject.toml index 6f58d0b..3513018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "httpx>=0.27", "hypercorn>=0.17", "bcrypt>=4.0", + "aiosmtplib>=3.0", ] [project.optional-dependencies] diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 8e9943b..f56ec82 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -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 diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index d9c5e03..5483539 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -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") diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index 0edd2b1..882c904 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -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 diff --git a/src/fabledassistant/models/app_log.py b/src/fabledassistant/models/app_log.py new file mode 100644 index 0000000..364ca5f --- /dev/null +++ b/src/fabledassistant/models/app_log.py @@ -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, + } diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index f4baec8..d5b1093 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -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) diff --git a/src/fabledassistant/routes/auth.py b/src/fabledassistant/routes/auth.py index 17e51cf..183c713 100644 --- a/src/fabledassistant/routes/auth.py +++ b/src/fabledassistant/routes/auth.py @@ -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"}) diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py index 20735b9..76781d1 100644 --- a/src/fabledassistant/services/assist.py +++ b/src/fabledassistant/services/assist.py @@ -1,4 +1,4 @@ -MAX_BODY_CHARS = 3000 +MAX_BODY_CHARS = 8000 def build_assist_messages( diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py index 0616a98..c217125 100644 --- a/src/fabledassistant/services/auth.py +++ b/src/fabledassistant/services/auth.py @@ -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: diff --git a/src/fabledassistant/services/email.py b/src/fabledassistant/services/email.py new file mode 100644 index 0000000..4b42f8e --- /dev/null +++ b/src/fabledassistant/services/email.py @@ -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 = """ +

+
+

Fabled Assistant

+
+
+

Your SMTP configuration is working correctly.

+

This is a test email sent from your Fabled Assistant instance.

+
+
+ """ + await send_email(to, "Fabled Assistant - Test Email", html) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 9631cc8..70255a0 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -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(): diff --git a/src/fabledassistant/services/logging.py b/src/fabledassistant/services/logging.py new file mode 100644 index 0000000..df8dac3 --- /dev/null +++ b/src/fabledassistant/services/logging.py @@ -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()) diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py new file mode 100644 index 0000000..92ad0dd --- /dev/null +++ b/src/fabledassistant/services/notifications.py @@ -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'{k}{v}' + + html = f""" +
+
+

Security Alert

+
+
+

{label}

+ + + + {detail_rows} +
Time{timestamp}
IP Address{ip}
+

If this wasn't you, change your password immediately.

+
+
+ """ + 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'{task.due_date.isoformat()}' if task.due_date else "" + overdue_badge = ' (overdue)' if overdue else "" + task_rows += f""" + + {task.title} + {date_label}{overdue_badge} + {task.status} + + """ + + html = f""" +
+
+

Task Reminders

+
+
+

You have {len(user_tasks)} task(s) due:

+ + + + + + + {task_rows} +
TaskDueStatus
+
+
+ """ + 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())