"""Persistent storage for draft attachments (proforma files) + MinIO mirroring. Dual-write policy (senior-engineer review 2026-10-25): every file the bot persists (voices, enhanced voices, proforma/user attachments) stays on disk under ./bot/data (primary copy, "archive — never delete") and is ALSO uploaded fire-and-forget to the ERP's existing MinIO under bucket `bot-media` with the SAME key as its path relative to the data dir, e.g. `voices/20261025/_.ogg` — the dashboard's media endpoint derives the object key from that relative path, so nothing extra is stored in payloads. """ from datetime import datetime, timezone import asyncio import logging import os from pathlib import Path import re import threading from uuid import UUID, uuid4 from app.config import settings logger = logging.getLogger("bot.storage") def sanitize_filename(filename: str) -> str: """Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions.""" raw = (filename or "proforma.pdf").strip() name = Path(raw).name # Remove control characters and path traversal clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name) clean = clean.strip(". ") return clean or f"proforma_{uuid4().hex[:8]}.pdf" async def save_proforma_file( draft_id: str | UUID, file_name: str, content: bytes, mime_type: str | None = None, storage_dir: str = "data/attachments/proformas", ) -> dict: """Save proforma bytes permanently to disk. Returns dict metadata for persisting in draft payload. """ clean_name = sanitize_filename(file_name) target_dir = Path(storage_dir) / str(draft_id) target_dir.mkdir(parents=True, exist_ok=True) unique_prefix = uuid4().hex[:8] saved_filename = f"{unique_prefix}_{clean_name}" target_path = target_dir / saved_filename # Write bytes to disk target_path.write_bytes(content) logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path) # Second copy in MinIO (never blocks/fails the bot; disabled when unset) rel = media_ref(target_path) if rel: await mirror_to_minio(rel, target_path, content_type=mime_type or "application/octet-stream") return { "file_name": clean_name, "saved_filename": saved_filename, "saved_path": str(target_path.resolve()), "relative_path": str(target_path.as_posix()), "media_ref": rel, "size_bytes": len(content), "mime_type": mime_type or "application/octet-stream", "saved_at": datetime.now(timezone.utc).isoformat(), } # --------------------------------------------------------------------------- # MinIO mirror — second copy of every bot media file # --------------------------------------------------------------------------- # The official `minio` Python client is synchronous/blocking; every SDK call # therefore runs in a worker thread (asyncio.to_thread) and the whole upload # is fire-and-forget so a MinIO outage can never stall the reply path. DATA_DIR = Path("data") # ./bot/data inside the container (compose volume) _client = None _client_lock = threading.Lock() _bg: set = set() def media_ref(path: str | Path) -> str | None: """Relative ref of a file under ./bot/data ('voices/.../x.ogg'), else None. This ref doubles as the MinIO object key and as the dashboard's media identifier — files outside the data dir are simply not mirrored/servable. """ try: rel = Path(path).resolve().relative_to((DATA_DIR).resolve()).as_posix() return rel if rel and ".." not in rel else None except ValueError: return None def _get_client(): """Lazily build the blocking MinIO client (None when unconfigured).""" global _client if not (settings.MINIO_ENDPOINT and settings.MINIO_ACCESS_KEY and settings.MINIO_SECRET_KEY): return None if _client is not None: return _client with _client_lock: if _client is not None: return _client try: from minio import Minio # imported only when configured _client = Minio(settings.MINIO_ENDPOINT, access_key=settings.MINIO_ACCESS_KEY, secret_key=settings.MINIO_SECRET_KEY, secure=settings.MINIO_SECURE) except Exception: logger.exception("storage: minio client init failed — mirroring disabled") _client = None return _client def _upload_sync(key: str, path: Path, content_type: str) -> None: client = _get_client() if client is None: return if not client.bucket_exists(settings.MINIO_BUCKET): client.make_bucket(settings.MINIO_BUCKET) logger.info("storage: created bucket %s", settings.MINIO_BUCKET) client.fput_object(settings.MINIO_BUCKET, key, str(path), content_type=content_type) async def mirror_to_minio(key: str, path: str | Path, content_type: str = "audio/ogg") -> None: """Fire-and-forget upload of an already-saved disk file (never raises).""" try: p = Path(path) if not p.is_file(): logger.warning("storage: mirror skipped — missing file %s", p) return async def _run() -> None: try: await asyncio.to_thread(_upload_sync, key, p, content_type) logger.info("storage: mirrored %s -> %s/%s", p.name, settings.MINIO_BUCKET, key) except Exception: logger.warning("storage: minio mirror failed for %s", p.name, exc_info=True) task = asyncio.get_running_loop().create_task(_run()) _bg.add(task) task.add_done_callback(_bg.discard) except RuntimeError: pass # no running loop (sync unit tests) — skip silently