"""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)