Newer
Older
navi-1 / navi / session_files.py
"""Session-scoped file storage with TTL-based cleanup."""

import asyncio
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path

import structlog

from navi.config import settings

log = structlog.get_logger()

# Extensions that must never be uploaded
_FORBIDDEN_EXTENSIONS = {
    ".exe", ".dll", ".so", ".dylib",
    ".sh", ".bash", ".zsh", ".fish",
    ".bat", ".cmd", ".ps1", ".vbs",
    ".com", ".msi", ".app", ".deb", ".rpm",
    ".bin", ".elf",
}


def session_dir(session_id: str) -> Path:
    return Path(settings.session_files_dir) / session_id


def ensure_session_dir(session_id: str) -> Path:
    d = session_dir(session_id)
    d.mkdir(parents=True, exist_ok=True)
    return d


def is_forbidden(filename: str) -> bool:
    return Path(filename).suffix.lower() in _FORBIDDEN_EXTENSIONS


def safe_filename(name: str) -> str:
    """Strip directory components and replace problematic chars."""
    name = Path(name).name  # strip any path components
    name = name.replace("/", "_").replace("\\", "_").replace("\x00", "_")
    return name or "upload"


def delete_session_dir(session_id: str) -> None:
    d = session_dir(session_id)
    if d.exists():
        shutil.rmtree(d)
        log.info("session_files.deleted", session_id=session_id)


async def cleanup_expired(store) -> None:
    """Remove session file dirs older than TTL; append assistant notice to those sessions."""
    root = Path(settings.session_files_dir)
    if not root.exists():
        return

    cutoff = datetime.now(timezone.utc) - timedelta(hours=settings.session_files_ttl_hours)

    for d in root.iterdir():
        if not d.is_dir():
            continue
        session_id = d.name
        try:
            session = await store.get(session_id)
        except Exception:
            continue

        if session is None:
            shutil.rmtree(d)
            continue

        if session.last_active >= cutoff:
            continue  # still fresh

        # Expired — delete directory and notify
        shutil.rmtree(d)
        log.info("session_files.ttl_expired", session_id=session_id)

        from navi.llm.base import Message

        notice = Message(
            role="assistant",
            content=(
                "Uploaded files for this session have been automatically deleted "
                f"after {settings.session_files_ttl_hours} hours of inactivity. "
                "Any files you previously uploaded are no longer available on disk."
            ),
        )
        session.messages.append(notice)
        try:
            await store.save(session)
        except Exception:
            log.warning("session_files.notice_save_failed", session_id=session_id, exc_info=True)


async def cleanup_loop(store) -> None:
    """Background task: scan for expired session file dirs every hour."""
    while True:
        try:
            await cleanup_expired(store)
        except Exception:
            log.exception("session_files.cleanup_error")
        await asyncio.sleep(3600)