diff --git a/app/api/__init__.py b/app/api/__init__.py index a75e02a4..87fd418d 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api.admin_users import router as admin_users_router from app.api.api_tokens import router as api_tokens_router +from app.api.audit_logs import router as audit_logs_router from app.api.azure import router as azure_router from app.api.backup import router as backup_router from app.api.billing import router as billing_router @@ -17,6 +18,7 @@ from app.api.dropbox import router as dropbox_router from app.api.duplicates import router as duplicates_router from app.api.files import router as files_router from app.api.google_drive import router as google_drive_router +from app.api.i18n import router as i18n_router from app.api.imap_accounts import router as imap_accounts_router from app.api.integrations import router as integrations_router from app.api.logs import router as logs_router @@ -83,4 +85,6 @@ router.include_router(imap_accounts_router) router.include_router(integrations_router) router.include_router(notifications_router) router.include_router(scheduled_jobs_router) +router.include_router(audit_logs_router) +router.include_router(i18n_router) router.include_router(mobile_router) diff --git a/app/api/audit_logs.py b/app/api/audit_logs.py new file mode 100644 index 00000000..a4ed9d10 --- /dev/null +++ b/app/api/audit_logs.py @@ -0,0 +1,115 @@ +""" +Audit log REST API endpoints. + +Provides read-only access to the comprehensive audit log for admin users. +Events are append-only — there are no update or delete endpoints. +""" + +import logging +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.orm import Session + +from app.auth import require_login +from app.database import get_db +from app.utils.audit_service import count_events, query_events + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/audit-logs") +@require_login +async def list_audit_logs( + request: Request, + db: Session = Depends(get_db), + action: str | None = Query(None, description="Filter by action (exact match)"), + user: str | None = Query(None, description="Filter by username"), + resource_type: str | None = Query(None, description="Filter by resource type"), + severity: str | None = Query(None, description="Filter by severity level"), + since: datetime | None = Query(None, description="Only events at or after this ISO-8601 timestamp"), + until: datetime | None = Query(None, description="Only events at or before this ISO-8601 timestamp"), + limit: int = Query(50, ge=1, le=500, description="Max rows to return"), + offset: int = Query(0, ge=0, description="Rows to skip for pagination"), +) -> dict[str, Any]: + """Return audit log entries with optional filtering and pagination. + + Requires authentication. Returns events in reverse chronological order. + """ + entries = query_events( + db, + action=action, + user=user, + resource_type=resource_type, + severity=severity, + since=since, + until=until, + limit=limit, + offset=offset, + ) + total = count_events( + db, + action=action, + user=user, + resource_type=resource_type, + severity=severity, + since=since, + until=until, + ) + return { + "items": [_serialize(e) for e in entries], + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.get("/audit-logs/actions") +@require_login +async def list_distinct_actions( + request: Request, + db: Session = Depends(get_db), +) -> list[str]: + """Return the distinct action values present in the audit log.""" + from app.models import AuditLog + + rows = db.query(AuditLog.action).distinct().order_by(AuditLog.action).all() + return [r[0] for r in rows] + + +@router.get("/audit-logs/users") +@require_login +async def list_distinct_users( + request: Request, + db: Session = Depends(get_db), +) -> list[str]: + """Return the distinct user values present in the audit log.""" + from app.models import AuditLog + + rows = db.query(AuditLog.user).distinct().order_by(AuditLog.user).all() + return [r[0] for r in rows] + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + + +def _serialize(entry) -> dict[str, Any]: + """Convert an AuditLog row to a JSON-safe dict.""" + import json as _json + + return { + "id": entry.id, + "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, + "user": entry.user, + "action": entry.action, + "resource_type": entry.resource_type, + "resource_id": entry.resource_id, + "ip_address": entry.ip_address, + "details": _json.loads(entry.details) if entry.details else None, + "severity": entry.severity, + } diff --git a/app/api/i18n.py b/app/api/i18n.py new file mode 100644 index 00000000..d8f5bbb3 --- /dev/null +++ b/app/api/i18n.py @@ -0,0 +1,136 @@ +"""API endpoints for internationalization (i18n). + +Provides endpoints for: +* Listing available languages +* Getting/setting user language preference (persisted in session + cookie + DB) +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import UserProfile +from app.utils.i18n import ( + DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGE_CODES, + SUPPORTED_LANGUAGES, + detect_language, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/i18n", tags=["i18n"]) + + +class LanguageInfo(BaseModel): + """Schema for a supported language.""" + + code: str + name: str + native: str + flag: str + + +class LanguageListResponse(BaseModel): + """Response for the list-languages endpoint.""" + + languages: list[LanguageInfo] + current: str + default: str + + +class SetLanguageRequest(BaseModel): + """Request body for setting the preferred language.""" + + language: str + + +class SetLanguageResponse(BaseModel): + """Response after changing the language.""" + + language: str + message: str + + +@router.get("/languages", response_model=LanguageListResponse) +async def list_languages(request: Request) -> LanguageListResponse: + """Return all supported UI languages and the current active language.""" + current = detect_language(request) + return LanguageListResponse( + languages=[LanguageInfo(**lang) for lang in SUPPORTED_LANGUAGES], + current=current, + default=DEFAULT_LANGUAGE, + ) + + +@router.post("/language", response_model=SetLanguageResponse) +async def set_language( + body: SetLanguageRequest, + request: Request, + response: Response, + db: Session = Depends(get_db), +) -> SetLanguageResponse: + """Set the preferred UI language. + + Persists the choice in: + 1. The server-side session + 2. A ``docuelevate_lang`` cookie (30-day expiry) + 3. The ``UserProfile.preferred_language`` column (if authenticated) + """ + lang = body.language.lower().strip() + if lang not in SUPPORTED_LANGUAGE_CODES: + lang = DEFAULT_LANGUAGE + + # 1. Session + if hasattr(request, "session"): + request.session["preferred_language"] = lang + + # 2. Cookie (30 days) + response.set_cookie( + key="docuelevate_lang", + value=lang, + max_age=30 * 24 * 60 * 60, + httponly=False, + samesite="lax", + ) + + # 3. Database (if user is authenticated) + _persist_language_to_profile(request, db, lang) + + language_name = next( + (entry["native"] for entry in SUPPORTED_LANGUAGES if entry["code"] == lang), + lang, + ) + logger.info("Language preference set to '%s'", lang) + return SetLanguageResponse( + language=lang, + message=f"Language changed to {language_name}", + ) + + +def _persist_language_to_profile(request: Request, db: Session, lang: str) -> None: + """Write language preference to the UserProfile row, if the user is logged in.""" + user_id: str | None = None + if hasattr(request, "session"): + user = request.session.get("user") + if isinstance(user, dict): + user_id = user.get("preferred_username") or user.get("email") or user.get("id") + elif isinstance(user, str): + user_id = user + + if not user_id: + return + + try: + profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first() + if profile: + profile.preferred_language = lang # type: ignore[attr-defined] + db.commit() + except Exception: + db.rollback() + logger.debug("Could not persist language preference for user_id=%s", user_id) diff --git a/app/config.py b/app/config.py index ba6eeb25..51df35e0 100644 --- a/app/config.py +++ b/app/config.py @@ -849,6 +849,49 @@ class Settings(BaseSettings): ), ) + # SIEM / External Audit Log Forwarding + # Forward audit events to external SIEM systems for centralised monitoring. + audit_siem_enabled: bool = Field( + default=False, + description="Enable forwarding of audit events to an external SIEM system.", + ) + audit_siem_transport: str = Field( + default="syslog", + description=( + "Transport used to forward audit events. " + "Options: 'syslog' (RFC 5424 over UDP/TCP), 'http' (JSON POST to a webhook URL, " + "compatible with Splunk HEC, Logstash HTTP input, Grafana Loki, etc.)." + ), + ) + audit_siem_syslog_host: str = Field( + default="localhost", + description="Hostname or IP of the syslog receiver.", + ) + audit_siem_syslog_port: int = Field( + default=514, + description="Port of the syslog receiver.", + ) + audit_siem_syslog_protocol: str = Field( + default="udp", + description="Protocol for syslog transport: 'udp' or 'tcp'.", + ) + audit_siem_http_url: str = Field( + default="", + description=( + "HTTP endpoint URL for SIEM webhook delivery. " + "Supports Splunk HEC (https://splunk:8088/services/collector/event), " + "Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint." + ), + ) + audit_siem_http_token: str = Field( + default="", + description="Bearer / HEC token included in the Authorization header of SIEM HTTP requests.", + ) + audit_siem_http_custom_headers: str = Field( + default="", + description="Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.", + ) + # UI / Appearance ui_default_color_scheme: str = Field( default="system", diff --git a/app/models.py b/app/models.py index e9d2030b..0bbb3b45 100644 --- a/app/models.py +++ b/app/models.py @@ -146,6 +146,27 @@ class SettingsAuditLog(Base): action = Column(String, nullable=False) # "update" or "delete" +class AuditLog(Base): + """Comprehensive audit log for compliance tracking. + + Records all significant actions: login/logout, document CRUD, settings + changes, and administrative operations. Rows are append-only; the API + and service layer never update or delete entries. + """ + + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + user = Column(String, nullable=False, index=True) # Username or "anonymous" / "system" + action = Column(String, nullable=False, index=True) # e.g. "login", "document.create", "settings.update" + resource_type = Column(String, nullable=True, index=True) # e.g. "document", "user", "settings" + resource_id = Column(String, nullable=True) # ID of the affected resource + ip_address = Column(String, nullable=True) # Client IP address + details = Column(Text, nullable=True) # JSON-encoded extra context + severity = Column(String(16), nullable=False, server_default="info") # info / warning / error / critical + + class SavedSearch(Base): """User-defined saved search filters for quick access to frequently used filter combinations.""" @@ -256,6 +277,10 @@ class UserProfile(Base): preferred_destination = Column(String(50), nullable=True) stripe_customer_id = Column(String(64), nullable=True) + # UI language preference for i18n (ISO 639-1 code, e.g. "en", "de", "fr") + # NULL means "auto-detect from browser Accept-Language header" + preferred_language = Column(String(10), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/utils/audit_service.py b/app/utils/audit_service.py new file mode 100644 index 00000000..73a6af7c --- /dev/null +++ b/app/utils/audit_service.py @@ -0,0 +1,331 @@ +""" +Comprehensive audit-event service for DocuElevate. + +Provides helpers to **record** audit events (append-only database writes) +and to optionally **forward** them to external SIEM systems. + +Supported SIEM transports: +* **Syslog** – RFC 5424 structured-data messages over UDP or TCP. +* **HTTP** – JSON POST payloads compatible with Splunk HEC, Logstash + HTTP input, Grafana Loki push API, and any generic webhook endpoint. +""" + +import json +import logging +import re +import socket +import threading +from datetime import datetime, timezone +from typing import Any + +import httpx +from fastapi import Request +from sqlalchemy.orm import Session + +from app.config import settings +from app.middleware.audit_log import get_client_ip, get_username +from app.models import AuditLog + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + + +def record_event( + db: Session, + *, + action: str, + user: str = "system", + resource_type: str | None = None, + resource_id: str | None = None, + ip_address: str | None = None, + details: dict[str, Any] | None = None, + severity: str = "info", +) -> AuditLog: + """Persist an audit event and optionally forward it to SIEM. + + Args: + db: Active SQLAlchemy session. + action: Short action identifier (e.g. ``"login"``, ``"document.create"``). + user: Username performing the action. + resource_type: Category of the affected resource (``"document"``, ``"user"`` …). + resource_id: Identifier of the affected resource. + ip_address: Client IP address (``None`` when not applicable). + details: Arbitrary key/value context serialised as JSON. + severity: One of ``info``, ``warning``, ``error``, ``critical``. + + Returns: + The newly created :class:`AuditLog` row. + """ + details_json = json.dumps(details, default=str) if details else None + + entry = AuditLog( + user=user, + action=action, + resource_type=resource_type, + resource_id=str(resource_id) if resource_id is not None else None, + ip_address=ip_address, + details=details_json, + severity=severity, + ) + db.add(entry) + db.commit() + db.refresh(entry) + + # Fire-and-forget SIEM forwarding in a background thread so we never + # block the request path. + if settings.audit_siem_enabled: + payload = _build_siem_payload(entry) + thread = threading.Thread(target=_forward_to_siem, args=(payload,), daemon=True) + thread.start() + + return entry + + +def record_event_from_request( + db: Session, + request: Request, + *, + action: str, + resource_type: str | None = None, + resource_id: str | None = None, + details: dict[str, Any] | None = None, + severity: str = "info", +) -> AuditLog: + """Convenience wrapper that extracts user and IP from a :class:`Request`. + + Args: + db: Active SQLAlchemy session. + request: The current HTTP request. + action: Short action identifier. + resource_type: Category of the affected resource. + resource_id: Identifier of the affected resource. + details: Arbitrary key/value context serialised as JSON. + severity: One of ``info``, ``warning``, ``error``, ``critical``. + + Returns: + The newly created :class:`AuditLog` row. + """ + return record_event( + db, + action=action, + user=get_username(request), + resource_type=resource_type, + resource_id=resource_id, + ip_address=get_client_ip(request), + details=details, + severity=severity, + ) + + +def query_events( + db: Session, + *, + action: str | None = None, + user: str | None = None, + resource_type: str | None = None, + severity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 200, + offset: int = 0, +) -> list[AuditLog]: + """Query audit log entries with optional filtering. + + Args: + db: Active SQLAlchemy session. + action: Filter by action string (exact match). + user: Filter by username (exact match). + resource_type: Filter by resource type (exact match). + severity: Filter by severity level (exact match). + since: Only events at or after this timestamp. + until: Only events at or before this timestamp. + limit: Maximum number of rows to return. + offset: Number of rows to skip (for pagination). + + Returns: + List of :class:`AuditLog` rows ordered by *timestamp descending*. + """ + q = db.query(AuditLog) + if action: + q = q.filter(AuditLog.action == action) + if user: + q = q.filter(AuditLog.user == user) + if resource_type: + q = q.filter(AuditLog.resource_type == resource_type) + if severity: + q = q.filter(AuditLog.severity == severity) + if since: + q = q.filter(AuditLog.timestamp >= since) + if until: + q = q.filter(AuditLog.timestamp <= until) + return q.order_by(AuditLog.timestamp.desc()).offset(offset).limit(limit).all() + + +def count_events( + db: Session, + *, + action: str | None = None, + user: str | None = None, + resource_type: str | None = None, + severity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, +) -> int: + """Return the total count of events matching the given filters. + + Args: + db: Active SQLAlchemy session. + action: Filter by action string. + user: Filter by username. + resource_type: Filter by resource type. + severity: Filter by severity level. + since: Only events at or after this timestamp. + until: Only events at or before this timestamp. + + Returns: + Integer count. + """ + q = db.query(AuditLog) + if action: + q = q.filter(AuditLog.action == action) + if user: + q = q.filter(AuditLog.user == user) + if resource_type: + q = q.filter(AuditLog.resource_type == resource_type) + if severity: + q = q.filter(AuditLog.severity == severity) + if since: + q = q.filter(AuditLog.timestamp >= since) + if until: + q = q.filter(AuditLog.timestamp <= until) + return q.count() + + +# --------------------------------------------------------------------------- +# SIEM forwarding internals +# --------------------------------------------------------------------------- + +_SYSLOG_FACILITY_LOCAL0 = 16 +_SYSLOG_SEVERITY_MAP = { + "info": 6, + "warning": 4, + "error": 3, + "critical": 2, +} + + +def _build_siem_payload(entry: AuditLog) -> dict[str, Any]: + """Convert an :class:`AuditLog` row into a plain dict for SIEM delivery.""" + ts = entry.timestamp if entry.timestamp else datetime.now(timezone.utc) + return { + "id": entry.id, + "timestamp": ts.isoformat(), + "user": entry.user, + "action": entry.action, + "resource_type": entry.resource_type, + "resource_id": entry.resource_id, + "ip_address": entry.ip_address, + "details": entry.details, + "severity": entry.severity, + "source": "docuelevate", + } + + +def _forward_to_siem(payload: dict[str, Any]) -> None: + """Route a SIEM payload to the configured transport.""" + transport = settings.audit_siem_transport.lower() + try: + if transport == "syslog": + _send_syslog(payload) + elif transport == "http": + _send_http(payload) + else: + logger.warning("Unknown SIEM transport %r; skipping forwarding", transport) + except Exception: + logger.exception("Failed to forward audit event to SIEM (%s)", transport) + + +def _send_syslog(payload: dict[str, Any]) -> None: + """Send a RFC 5424 syslog message to the configured receiver.""" + severity_num = _SYSLOG_SEVERITY_MAP.get(payload.get("severity", "info"), 6) + priority = _SYSLOG_FACILITY_LOCAL0 * 8 + severity_num + ts = payload.get("timestamp", datetime.now(timezone.utc).isoformat()) + hostname = socket.gethostname() + app_name = "docuelevate" + msg_id = payload.get("action", "-") + + # Structured data (SD) element with key event fields. + sd = ( + f'[docuelevate@0 user="{payload.get("user", "-")}" ' + f'action="{payload.get("action", "-")}" ' + f'resource_type="{payload.get("resource_type", "-")}" ' + f'resource_id="{payload.get("resource_id", "-")}" ' + f'ip="{payload.get("ip_address", "-")}"]' + ) + message = json.dumps(payload, default=str) + syslog_msg = f"<{priority}>1 {ts} {hostname} {app_name} - {msg_id} {sd} {message}" + + proto = settings.audit_siem_syslog_protocol.lower() + host = settings.audit_siem_syslog_host + port = settings.audit_siem_syslog_port + + if proto == "tcp": + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(5) + sock.connect((host, port)) + sock.sendall(syslog_msg.encode("utf-8")) + else: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(5) + sock.sendto(syslog_msg.encode("utf-8"), (host, port)) + + logger.debug("Syslog audit event sent to %s:%s (%s)", host, port, proto) + + +def _send_http(payload: dict[str, Any]) -> None: + """POST a JSON audit event to the configured HTTP endpoint.""" + url = settings.audit_siem_http_url + if not url: + logger.warning("SIEM HTTP URL not configured; skipping HTTP forwarding") + return + + headers: dict[str, str] = {"Content-Type": "application/json"} + token = settings.audit_siem_http_token + if token: + headers["Authorization"] = f"Bearer {token}" + + # Parse custom headers (comma-separated "Key:Value" pairs). + # Reject headers that could override security-critical ones already set, + # and validate that header names contain only RFC 7230 token characters. + _PROTECTED_HEADERS = {"authorization", "content-type", "host"} + _VALID_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$") + raw_custom = settings.audit_siem_http_custom_headers + if raw_custom: + for raw_pair in raw_custom.split(","): + pair = raw_pair.strip() + if ":" in pair: + k, _, v = pair.partition(":") + name = k.strip() + if not name or not _VALID_HEADER_NAME.match(name): + logger.warning("Skipping invalid SIEM custom header name: %r", name) + continue + if name.lower() in _PROTECTED_HEADERS: + logger.warning("Skipping protected SIEM custom header: %r", name) + continue + headers[name] = v.strip() + + # Wrap in Splunk HEC-style envelope when URL contains ``/services/collector``. + body: dict[str, Any] + if "/services/collector" in url: + body = {"event": payload, "sourcetype": "docuelevate:audit", "source": "docuelevate"} + else: + body = payload + + with httpx.Client(timeout=10) as client: + resp = client.post(url, json=body, headers=headers) + resp.raise_for_status() + + logger.debug("HTTP audit event forwarded to %s (status %s)", url, resp.status_code) diff --git a/app/utils/db_migrate.py b/app/utils/db_migrate.py index f95d97f0..3424009d 100644 --- a/app/utils/db_migrate.py +++ b/app/utils/db_migrate.py @@ -32,8 +32,10 @@ _TABLE_ORDER = [ "processing_logs", "application_settings", "settings_audit_log", + "audit_logs", "saved_searches", "webhook_configs", + "shared_links", ] diff --git a/app/utils/i18n.py b/app/utils/i18n.py new file mode 100644 index 00000000..04055adc --- /dev/null +++ b/app/utils/i18n.py @@ -0,0 +1,539 @@ +"""Internationalization (i18n) and localization (l10n) utilities. + +Provides a JSON-based translation system for the DocuElevate UI with: + +* **31 supported languages** covering all major European languages plus ZH +* Browser ``Accept-Language`` detection with cookie & user-profile persistence +* AI-powered fallback translation via the configured LLM provider +* Locale-aware date, number, and file-size formatting helpers +* Jinja2 integration via a ``_()`` global function + +Language resolution order: + 1. User profile ``preferred_language`` (persisted in DB) + 2. ``docuelevate_lang`` cookie + 3. ``Accept-Language`` HTTP header + 4. Default (``en``) +""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime +from functools import lru_cache +from pathlib import Path +from typing import Any + +from starlette.requests import Request + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Supported languages (ordered by priority) +# --------------------------------------------------------------------------- + +SUPPORTED_LANGUAGES: list[dict[str, str]] = [ + # --- Tier 1: Primary European languages --- + {"code": "en", "name": "English", "native": "English", "flag": "🇬🇧"}, + {"code": "de", "name": "German", "native": "Deutsch", "flag": "🇩🇪"}, + {"code": "fr", "name": "French", "native": "Français", "flag": "🇫🇷"}, + {"code": "es", "name": "Spanish", "native": "Español", "flag": "🇪🇸"}, + {"code": "it", "name": "Italian", "native": "Italiano", "flag": "🇮🇹"}, + {"code": "pt", "name": "Portuguese", "native": "Português", "flag": "🇵🇹"}, + # --- Tier 2: Western & Northern European --- + {"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "🇳🇱"}, + {"code": "nb", "name": "Norwegian", "native": "Norsk", "flag": "🇳🇴"}, + {"code": "da", "name": "Danish", "native": "Dansk", "flag": "🇩🇰"}, + {"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "🇸🇪"}, + {"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "🇫🇮"}, + {"code": "is", "name": "Icelandic", "native": "Íslenska", "flag": "🇮🇸"}, + {"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "🇮🇪"}, + {"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "🇱🇺"}, + {"code": "ca", "name": "Catalan", "native": "Català", "flag": "🏴"}, + # --- Tier 3: Central & Eastern European --- + {"code": "pl", "name": "Polish", "native": "Polski", "flag": "🇵🇱"}, + {"code": "cs", "name": "Czech", "native": "Čeština", "flag": "🇨🇿"}, + {"code": "sk", "name": "Slovak", "native": "Slovenčina", "flag": "🇸🇰"}, + {"code": "hu", "name": "Hungarian", "native": "Magyar", "flag": "🇭🇺"}, + {"code": "sl", "name": "Slovenian", "native": "Slovenščina", "flag": "🇸🇮"}, + {"code": "hr", "name": "Croatian", "native": "Hrvatski", "flag": "🇭🇷"}, + {"code": "ro", "name": "Romanian", "native": "Română", "flag": "🇷🇴"}, + {"code": "bg", "name": "Bulgarian", "native": "Български", "flag": "🇧🇬"}, + {"code": "el", "name": "Greek", "native": "Ελληνικά", "flag": "🇬🇷"}, + {"code": "et", "name": "Estonian", "native": "Eesti", "flag": "🇪🇪"}, + {"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "🇱🇻"}, + {"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "🇱🇹"}, + # --- Tier 4: Non-EU European & Other --- + {"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "🇹🇷"}, + {"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "🇺🇦"}, + {"code": "ru", "name": "Russian", "native": "Русский", "flag": "🇷🇺"}, + {"code": "zh", "name": "Chinese", "native": "中文", "flag": "🇨🇳"}, +] + +SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES} +DEFAULT_LANGUAGE = "en" + +# --------------------------------------------------------------------------- +# Translation file loading +# --------------------------------------------------------------------------- + +_TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "translations" +_translation_cache: dict[str, dict[str, str]] = {} + + +def _load_translations(locale: str) -> dict[str, str]: + """Load the translation JSON file for *locale*, with caching.""" + if locale in _translation_cache: + return _translation_cache[locale] + + filepath = _TRANSLATIONS_DIR / f"{locale}.json" + if not filepath.is_file(): + logger.warning("Translation file not found for locale '%s'", locale) + _translation_cache[locale] = {} + return {} + + try: + data: dict[str, str] = json.loads(filepath.read_text(encoding="utf-8")) + _translation_cache[locale] = data + return data + except (json.JSONDecodeError, OSError): + logger.exception("Failed to load translations for '%s'", locale) + _translation_cache[locale] = {} + return {} + + +def reload_translations() -> None: + """Clear the translation cache so files are re-read on next access.""" + _translation_cache.clear() + + +# --------------------------------------------------------------------------- +# Core translation function +# --------------------------------------------------------------------------- + + +def translate(key: str, locale: str | None = None, **kwargs: Any) -> str: + """Return the translated string for *key* in *locale*. + + Falls back through: + 1. Requested *locale* + 2. English (``en``) + 3. The raw key itself (to keep the UI functional) + + Positional placeholders ``{0}``, ``{1}`` or named placeholders + ``{name}`` in the translated string are interpolated via *kwargs*. + """ + locale = locale if locale and locale in SUPPORTED_LANGUAGE_CODES else DEFAULT_LANGUAGE + + translations = _load_translations(locale) + value = translations.get(key) + + # Fallback to English + if value is None and locale != DEFAULT_LANGUAGE: + en_translations = _load_translations(DEFAULT_LANGUAGE) + value = en_translations.get(key) + + # Fallback to key itself + if value is None: + value = key + + if kwargs: + try: + value = value.format(**kwargs) + except (KeyError, IndexError): + pass # Return unformatted string rather than crash + + return value + + +# --------------------------------------------------------------------------- +# AI fallback translation (best-effort, non-blocking) +# --------------------------------------------------------------------------- + +_ai_translation_cache: dict[tuple[str, str], str] = {} + + +def translate_with_ai_fallback(text: str, target_locale: str) -> str: + """Translate *text* using the configured AI provider as a fallback. + + Returns the original *text* unchanged when: + * The target locale is English (source language) + * The AI provider is unavailable or returns an error + * The translation has already been cached + + Results are cached in-memory for the lifetime of the process. + """ + if target_locale == DEFAULT_LANGUAGE or target_locale not in SUPPORTED_LANGUAGE_CODES: + return text + + cache_key = (text, target_locale) + if cache_key in _ai_translation_cache: + return _ai_translation_cache[cache_key] + + target_name = next( + (lang["name"] for lang in SUPPORTED_LANGUAGES if lang["code"] == target_locale), + target_locale, + ) + + try: + from litellm import completion # type: ignore[import-untyped] + + from app.config import settings + + model = getattr(settings, "ai_model", None) or getattr(settings, "openai_model", "gpt-4o-mini") + response = completion( + model=model, + messages=[ + { + "role": "system", + "content": ( + f"You are a professional translator. Translate the following UI text " + f"from English to {target_name}. Return ONLY the translated text, " + f"nothing else. Keep any HTML tags, placeholders like {{name}}, " + f"and special characters intact." + ), + }, + {"role": "user", "content": text}, + ], + max_tokens=256, + temperature=0.1, + ) + translated = response.choices[0].message.content.strip() + _ai_translation_cache[cache_key] = translated + return translated + except Exception: + logger.debug("AI fallback translation failed for '%s' → %s", text[:50], target_locale) + return text + + +# --------------------------------------------------------------------------- +# Language detection +# --------------------------------------------------------------------------- + + +def detect_language(request: Request) -> str: + """Determine the preferred UI language from the request context. + + Resolution order: + 1. ``preferred_language`` stored in the user session + 2. ``docuelevate_lang`` cookie + 3. ``Accept-Language`` HTTP header (best match) + 4. Default → ``en`` + """ + # 1. User session preference + if hasattr(request, "session"): + session_lang = request.session.get("preferred_language") + if session_lang and session_lang in SUPPORTED_LANGUAGE_CODES: + return session_lang + + # 2. Cookie + cookie_lang = request.cookies.get("docuelevate_lang") + if cookie_lang and cookie_lang in SUPPORTED_LANGUAGE_CODES: + return cookie_lang + + # 3. Accept-Language header + accept = request.headers.get("accept-language", "") + lang = _parse_accept_language(accept) + if lang: + return lang + + return DEFAULT_LANGUAGE + + +def _parse_accept_language(header: str) -> str | None: + """Extract the best matching language from an ``Accept-Language`` header. + + Parses quality values and returns the highest-priority match among + :data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches. + """ + if not header: + return None + + entries: list[tuple[float, str]] = [] + for raw_part in header.split(","): + part = raw_part.strip() + if not part: + continue + if ";q=" in part: + lang_tag, _, q_str = part.partition(";q=") + try: + quality = float(q_str.strip()) + except ValueError: + quality = 0.0 + else: + lang_tag = part + quality = 1.0 + entries.append((quality, lang_tag.strip().lower())) + + # Sort by quality descending + entries.sort(key=lambda e: e[0], reverse=True) + + for _quality, tag in entries: + # Try exact match first (e.g., "de", "zh") + code = tag.split("-")[0] + if code in SUPPORTED_LANGUAGE_CODES: + return code + + return None + + +# --------------------------------------------------------------------------- +# Localization helpers (l10n) +# --------------------------------------------------------------------------- + +# Locale-specific formatting rules for date/number display +_LOCALE_FORMATS: dict[str, dict[str, Any]] = { + "en": { + "date": "%B %d, %Y", + "date_short": "%m/%d/%Y", + "datetime": "%B %d, %Y %I:%M %p", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "de": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "fr": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u202f", + "decimal_sep": ",", + }, + "es": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "it": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "pt": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "nl": { + "date": "%d %B %Y", + "date_short": "%d-%m-%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "nb": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "da": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "sv": { + "date": "%d %B %Y", + "date_short": "%Y-%m-%d", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "fi": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "is": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "ga": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "lb": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "ca": { + "date": "%d de %B de %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d de %B de %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "pl": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "cs": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "sk": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "hu": { + "date": "%Y. %B %d.", + "date_short": "%Y.%m.%d.", + "datetime": "%Y. %B %d. %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "sl": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "hr": { + "date": "%d. %B %Y.", + "date_short": "%d.%m.%Y.", + "datetime": "%d. %B %Y. %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "ro": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "bg": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "el": { + "date": "%d %B %Y", + "date_short": "%d/%m/%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "et": { + "date": "%d. %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d. %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "lv": { + "date": "%Y. gada %d. %B", + "date_short": "%d.%m.%Y.", + "datetime": "%Y. gada %d. %B %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "lt": { + "date": "%Y m. %B %d d.", + "date_short": "%Y-%m-%d", + "datetime": "%Y m. %B %d d. %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "tr": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": ".", + "decimal_sep": ",", + }, + "uk": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, + "zh": { + "date": "%Y年%m月%d日", + "date_short": "%Y/%m/%d", + "datetime": "%Y年%m月%d日 %H:%M", + "thousands_sep": ",", + "decimal_sep": ".", + }, + "ru": { + "date": "%d %B %Y", + "date_short": "%d.%m.%Y", + "datetime": "%d %B %Y %H:%M", + "thousands_sep": "\u00a0", + "decimal_sep": ",", + }, +} + + +def format_date(value: date | datetime | None, locale: str = DEFAULT_LANGUAGE, short: bool = False) -> str: + """Format a date/datetime value according to the locale conventions.""" + if value is None: + return "" + fmt_key = "date_short" if short else "date" + fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])[fmt_key] + return value.strftime(fmt) + + +def format_datetime(value: datetime | None, locale: str = DEFAULT_LANGUAGE) -> str: + """Format a datetime value according to the locale conventions.""" + if value is None: + return "" + fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])["datetime"] + return value.strftime(fmt) + + +def format_number(value: int | float, locale: str = DEFAULT_LANGUAGE) -> str: + """Format a number with locale-appropriate thousand separators.""" + lf = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE]) + if isinstance(value, float): + int_part, _, dec_part = f"{value:,.2f}".partition(".") + formatted_int = int_part.replace(",", lf["thousands_sep"]) + return f"{formatted_int}{lf['decimal_sep']}{dec_part}" + return f"{value:,}".replace(",", lf["thousands_sep"]) + + +@lru_cache(maxsize=32) +def get_language_info(code: str) -> dict[str, str] | None: + """Return the metadata dict for a supported language code, or ``None``.""" + for lang in SUPPORTED_LANGUAGES: + if lang["code"] == code: + return lang + return None diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 7516f8f9..39ed812e 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -2065,6 +2065,78 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "audit_siem_enabled": { + "category": "Security", + "description": "Enable forwarding of audit events to an external SIEM system (Syslog, Splunk, Logstash, etc.).", + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_transport": { + "category": "Security", + "description": ( + "Transport used to forward audit events. 'syslog' sends RFC 5424 messages over UDP/TCP. " + "'http' sends JSON POST payloads to a webhook URL (Splunk HEC, Logstash, Grafana Loki, etc.)." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + "options": ["syslog", "http"], + }, + "audit_siem_syslog_host": { + "category": "Security", + "description": "Hostname or IP of the syslog receiver.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_syslog_port": { + "category": "Security", + "description": "Port of the syslog receiver. Default: 514.", + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_syslog_protocol": { + "category": "Security", + "description": "Protocol for syslog transport: 'udp' or 'tcp'. Default: udp.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + "options": ["udp", "tcp"], + }, + "audit_siem_http_url": { + "category": "Security", + "description": ( + "HTTP endpoint URL for SIEM webhook delivery. Supports Splunk HEC, " + "Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "audit_siem_http_token": { + "category": "Security", + "description": "Bearer / HEC token included in the Authorization header of SIEM HTTP requests.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": False, + }, + "audit_siem_http_custom_headers": { + "category": "Security", + "description": "Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, # Rate Limiting "rate_limiting_enabled": { "category": "Security", diff --git a/app/views/__init__.py b/app/views/__init__.py index 5c098527..1b2b0443 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -6,6 +6,7 @@ from fastapi import APIRouter from app.views.admin_users import router as admin_users_router from app.views.api_tokens import router as api_tokens_router +from app.views.audit_logs import router as audit_logs_router from app.views.backup import router as backup_router from app.views.db_wizard import router as db_wizard_router from app.views.dropbox import router as dropbox_router @@ -60,4 +61,5 @@ router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts router.include_router(integrations_router) # Unified integrations dashboard router.include_router(notifications_router) # User notification dashboard router.include_router(scheduled_jobs_router) # Admin scheduled batch jobs +router.include_router(audit_logs_router) # Comprehensive audit log viewer router.include_router(help_router) # Built-in help / How-To docs diff --git a/app/views/audit_logs.py b/app/views/audit_logs.py new file mode 100644 index 00000000..a787fece --- /dev/null +++ b/app/views/audit_logs.py @@ -0,0 +1,46 @@ +""" +Audit log viewer UI — admin-only page with filtering and SIEM status. +""" + +import logging + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from app.views.base import APIRouter, get_db, require_login, settings, templates +from app.views.settings import require_admin_access + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/admin/audit-logs") +@require_login +@require_admin_access +async def audit_logs_page(request: Request, db: Session = Depends(get_db)): + """Comprehensive audit log viewer with filtering controls. + + Displays a chronological log of all significant actions: logins, + document operations, settings changes, and admin actions. The + actual data is fetched client-side via the ``/api/audit-logs`` JSON + endpoint so that filters, pagination, and live refresh work without + full-page reloads. + """ + try: + siem_enabled = settings.audit_siem_enabled + siem_transport = settings.audit_siem_transport if siem_enabled else None + return templates.TemplateResponse( + "audit_logs.html", + { + "request": request, + "app_version": settings.version, + "siem_enabled": siem_enabled, + "siem_transport": siem_transport, + }, + ) + except Exception as e: + logger.error("Error loading audit logs page: %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load audit logs page", + ) diff --git a/app/views/base.py b/app/views/base.py index 010d4f21..fc9d5e0d 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -12,6 +12,14 @@ from sqlalchemy.orm import Session # noqa: F401 from app.auth import require_login # noqa: F401 from app.config import settings from app.database import get_db # noqa: F401 +from app.utils.i18n import ( + SUPPORTED_LANGUAGES, + detect_language, + format_date, + format_datetime, + format_number, + translate, +) # Set up Jinja2 templates templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates" @@ -21,6 +29,16 @@ templates = Jinja2Templates(directory=str(templates_dir)) templates.env.globals["min"] = min templates.env.globals["max"] = max +# --------------------------------------------------------------------------- +# i18n Jinja2 integration +# --------------------------------------------------------------------------- +# The _() function is available in every template to translate UI strings. +# Usage: {{ _("nav.dashboard") }} or {{ _("upload.max_size", size="10 MB") }} +# The locale is automatically resolved from the request context. +# --------------------------------------------------------------------------- + +templates.env.globals["supported_languages"] = SUPPORTED_LANGUAGES + # Customize Jinja2Templates to include app_version in all templates original_template_response = templates.TemplateResponse @@ -48,8 +66,31 @@ def _inject_global_context(ctx: dict) -> None: session_user = req.session.get("user") # When auth is disabled every visitor is effectively "logged in" ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None) + + # --- i18n: detect language and register template helpers --- + current_locale = detect_language(req) + ctx.setdefault("current_locale", current_locale) + + def _translate(key: str, **kwargs: object) -> str: + return translate(key, current_locale, **kwargs) + + def _format_date(value: object, short: bool = False) -> str: + return format_date(value, current_locale, short=short) # type: ignore[arg-type] + + def _format_datetime(value: object) -> str: + return format_datetime(value, current_locale) # type: ignore[arg-type] + + def _format_number(value: object) -> str: + return format_number(value, current_locale) # type: ignore[arg-type] + + ctx.setdefault("_", _translate) + ctx.setdefault("format_date_l10n", _format_date) + ctx.setdefault("format_datetime_l10n", _format_datetime) + ctx.setdefault("format_number_l10n", _format_number) else: ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True)) + ctx.setdefault("current_locale", "en") + ctx.setdefault("_", lambda key, **kw: translate(key, "en", **kw)) def template_response_with_version(*args, **kwargs): diff --git a/migrations/env.py b/migrations/env.py index 19785a0c..086b4ba5 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -20,14 +20,30 @@ from app.database import Base # Ensure all models are imported so Base.metadata is populated. from app.models import ( # noqa: F401 + ApiToken, ApplicationSettings, + AuditLog, + BackupRecord, DocumentMetadata, FileProcessingStep, FileRecord, + InAppNotification, + LocalUser, MobileDevice, + Pipeline, + PipelineStep, ProcessingLog, SavedSearch, + ScheduledJob, SettingsAuditLog, + SharedLink, + SubscriptionPlan, + UserImapAccount, + UserIntegration, + UserNotificationPreference, + UserNotificationTarget, + UserProfile, + WebhookConfig, ) # Alembic Config object – provides access to values in alembic.ini. diff --git a/migrations/versions/027_ensure_shared_links_table.py b/migrations/versions/027_ensure_shared_links_table.py new file mode 100644 index 00000000..2a333582 --- /dev/null +++ b/migrations/versions/027_ensure_shared_links_table.py @@ -0,0 +1,62 @@ +"""Ensure shared_links table exists for databases that skipped migration 025. + +Databases that were already at revision 025_add_user_notifications or +026_add_scheduled_jobs before 025_add_shared_links was inserted into the +migration chain will never have had the ``shared_links`` table created. +This migration creates the table idempotently so those databases are +repaired on the next ``alembic upgrade head``. + +Revision ID: 027_ensure_shared_links_table +Revises: 026_add_scheduled_jobs +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "027_ensure_shared_links_table" +down_revision: Union[str, None] = "026_add_scheduled_jobs" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create shared_links table if it does not already exist.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "shared_links" not in inspector.get_table_names(): + op.create_table( + "shared_links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(64), nullable=False), + sa.Column("file_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.String(), nullable=False), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("max_views", sa.Integer(), nullable=True), + sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("password_hash", sa.String(128), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["file_id"], ["files.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + ) + op.create_index("ix_shared_links_id", "shared_links", ["id"]) + op.create_index("ix_shared_links_token", "shared_links", ["token"]) + op.create_index("ix_shared_links_file_id", "shared_links", ["file_id"]) + op.create_index("ix_shared_links_owner_id", "shared_links", ["owner_id"]) + + +def downgrade() -> None: + """Drop shared_links table only if this migration created it.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + if "shared_links" in inspector.get_table_names(): + op.drop_index("ix_shared_links_owner_id", "shared_links") + op.drop_index("ix_shared_links_file_id", "shared_links") + op.drop_index("ix_shared_links_token", "shared_links") + op.drop_index("ix_shared_links_id", "shared_links") + op.drop_table("shared_links") diff --git a/migrations/versions/028_add_audit_logs.py b/migrations/versions/028_add_audit_logs.py new file mode 100644 index 00000000..58114e7a --- /dev/null +++ b/migrations/versions/028_add_audit_logs.py @@ -0,0 +1,47 @@ +"""Add audit_logs table for comprehensive compliance audit logging. + +Revision ID: 028_add_audit_logs +Revises: 027_ensure_shared_links_table +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "028_add_audit_logs" +down_revision: Union[str, None] = "027_ensure_shared_links_table" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create audit_logs table.""" + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("user", sa.String(), nullable=False), + sa.Column("action", sa.String(), nullable=False), + sa.Column("resource_type", sa.String(), nullable=True), + sa.Column("resource_id", sa.String(), nullable=True), + sa.Column("ip_address", sa.String(), nullable=True), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("severity", sa.String(16), nullable=False, server_default="info"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_audit_logs_id", "audit_logs", ["id"]) + op.create_index("ix_audit_logs_timestamp", "audit_logs", ["timestamp"]) + op.create_index("ix_audit_logs_user", "audit_logs", ["user"]) + op.create_index("ix_audit_logs_action", "audit_logs", ["action"]) + op.create_index("ix_audit_logs_resource_type", "audit_logs", ["resource_type"]) + + +def downgrade() -> None: + """Drop audit_logs table.""" + op.drop_index("ix_audit_logs_resource_type", "audit_logs") + op.drop_index("ix_audit_logs_action", "audit_logs") + op.drop_index("ix_audit_logs_user", "audit_logs") + op.drop_index("ix_audit_logs_timestamp", "audit_logs") + op.drop_index("ix_audit_logs_id", "audit_logs") + op.drop_table("audit_logs") diff --git a/migrations/versions/029_add_user_language_preference.py b/migrations/versions/029_add_user_language_preference.py new file mode 100644 index 00000000..632f0040 --- /dev/null +++ b/migrations/versions/029_add_user_language_preference.py @@ -0,0 +1,28 @@ +"""Add preferred_language column to user_profiles for i18n support. + +Revision ID: 029_add_user_language_preference +Revises: 028_add_audit_logs +Create Date: 2026-03-09 +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "029_add_user_language_preference" +down_revision: Union[str, None] = "028_add_audit_logs" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Add preferred_language column to user_profiles table.""" + op.add_column( + "user_profiles", + sa.Column("preferred_language", sa.String(10), nullable=True, server_default=None), + ) + + +def downgrade() -> None: + """Remove preferred_language column from user_profiles table.""" + op.drop_column("user_profiles", "preferred_language") diff --git a/migrations/versions/027_add_mobile_devices.py b/migrations/versions/030_add_mobile_devices.py similarity index 88% rename from migrations/versions/027_add_mobile_devices.py rename to migrations/versions/030_add_mobile_devices.py index c9732be9..c73773ef 100644 --- a/migrations/versions/027_add_mobile_devices.py +++ b/migrations/versions/030_add_mobile_devices.py @@ -1,7 +1,7 @@ """Add mobile_devices table for push notification device registration. -Revision ID: 027_add_mobile_devices -Revises: 026_add_scheduled_jobs +Revision ID: 030_add_mobile_devices +Revises: 029_add_user_language_preference Create Date: 2026-03-10 """ @@ -10,8 +10,8 @@ from typing import Union import sqlalchemy as sa from alembic import op -revision: str = "027_add_mobile_devices" -down_revision: Union[str, None] = "026_add_scheduled_jobs" +revision: str = "030_add_mobile_devices" +down_revision: Union[str, None] = "029_add_user_language_preference" depends_on: Union[str, None] = None