"""Session-scoped file storage.
Session directories live as long as the session itself exists in the database.
They are removed only when the session is explicitly deleted (DELETE /sessions/{id})
or when an orphaned directory is found (session no longer in DB).
"""
import asyncio
import base64
import hashlib
import io
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
import structlog
from PIL import Image
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"
# The formats `image_view` can open. A pasted image is written to disk for exactly
# that tool to reach, so this set is the one worth naming files after.
_IMAGE_EXTENSIONS = {
"PNG": ".png",
"JPEG": ".jpg",
"WEBP": ".webp",
"GIF": ".gif",
"BMP": ".bmp",
}
def _decode_image(data: str) -> bytes:
"""Decode one image from the chat, tolerating a `data:` prefix."""
raw = data.split(",", 1)[1] if data.startswith("data:") and "," in data else data
return base64.b64decode(raw)
def _sniff_extension(raw: bytes) -> str | None:
"""The extension for what the bytes actually are, or None if they are not an image."""
with Image.open(io.BytesIO(raw)) as image:
return _IMAGE_EXTENSIONS.get((image.format or "").upper())
_STORED_NAME = re.compile(r"image_\d+")
def _stored_images(directory: Path) -> list[Path]:
"""The pasted images already in the directory, in the order they were numbered.
Only names this module writes count: an upload called `image_notes.md` shares the
prefix but not the shape, and must not steal a number from the sequence.
"""
stored = [
path
for path in directory.iterdir()
if path.is_file()
and _STORED_NAME.fullmatch(path.stem)
and path.suffix.lower() in _IMAGE_EXTENSIONS.values()
]
return sorted(stored, key=lambda path: int(path.stem.split("_", 1)[1]))
def _store_image(directory: Path, raw: bytes, ext: str) -> str:
"""Write `raw` under a free `image_N<ext>` name and return that name.
Numbering runs across every pasted image, not per format, so the directory reads as
the order the user sent them in. Bytes already on disk are reused rather than written
again, so a message that was sent twice does not litter the directory with copies.
"""
digest = hashlib.sha256(raw).digest()
stored = _stored_images(directory)
for path in stored:
if path.stat().st_size == len(raw) and hashlib.sha256(path.read_bytes()).digest() == digest:
return path.name
taken = {int(path.stem.split("_", 1)[1]) for path in stored}
number = 1
while number in taken:
number += 1
name = f"image_{number}{ext}"
(directory / name).write_bytes(raw)
return name
def save_images(session_id: str, images: list[str]) -> list[str]:
"""Write the images a user pasted into the chat into the session directory.
Returns one name per input image, in order; an empty string marks one that could
not be stored. The bytes are written exactly as they arrived — re-encoding to JPEG
would drop the alpha channel of a pasted PNG, which is often the point of pasting it.
Raising is left to the caller for the one failure it can act on: an `OSError` when
the session directory itself cannot be created. A single unreadable image is logged
and skipped so it cannot cost the rest of the message.
"""
directory = ensure_session_dir(session_id)
names: list[str] = []
for index, data in enumerate(images, start=1):
try:
raw = _decode_image(data)
ext = _sniff_extension(raw)
if ext is None:
raise ValueError("unrecognised image format")
names.append(_store_image(directory, raw, ext))
except (OSError, ValueError) as exc:
log.warning(
"session_files.image_not_stored",
session_id=session_id,
index=index,
error=str(exc),
)
names.append("")
return names
async def delete_session_dir(session_id: str) -> None:
d = session_dir(session_id)
if d.exists():
await asyncio.to_thread(shutil.rmtree, d)
log.info("session_files.deleted", session_id=session_id)
async def cleanup_orphans(store) -> None:
"""Remove session file dirs whose session no longer exists in the database."""
root = Path(settings.session_files_dir)
if not root.exists():
return
entries = await asyncio.to_thread(lambda: list(root.iterdir()))
for d in entries:
if not d.is_dir():
continue
session_id = d.name
try:
session = await store.get(session_id)
except Exception:
continue
if session is None:
await asyncio.to_thread(shutil.rmtree, d)
log.info("session_files.orphan_deleted", session_id=session_id)
def list_session_files(session_id: str, max_depth: int = 10) -> list[dict]:
"""Recursively list files and directories in the session directory.
Returns a flat list ordered by path, each item containing:
{ path, size, is_dir, modified_at }.
Hidden files (starting with '.') are included.
"""
base = session_dir(session_id)
if not base.exists():
return []
out = []
_scan_dir(base, base, out, current_depth=0, max_depth=max_depth)
return out
def _scan_dir(root: Path, current: Path, out: list, current_depth: int, max_depth: int) -> None:
if current_depth > max_depth:
return
try:
entries = sorted(current.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
except (PermissionError, OSError):
return
for entry in entries:
try:
stat = entry.stat()
rel = entry.relative_to(root).as_posix()
out.append({
"path": rel,
"size": stat.st_size if entry.is_file() else 0,
"is_dir": entry.is_dir(),
"modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
})
if entry.is_dir():
_scan_dir(root, entry, out, current_depth + 1, max_depth)
except (PermissionError, OSError):
continue
async def cleanup_loop(store) -> None:
"""Background task: remove orphaned session file dirs every hour."""
while True:
try:
await cleanup_orphans(store)
except Exception:
log.exception("session_files.cleanup_error")
await asyncio.sleep(3600)