""" Path Token Encryption Encrypts file paths into opaque, time-limited Fernet tokens so raw filesystem paths are never exposed in API URLs. encode_path("/opt/immich/paid/creator/file.mp4") # => "gAAAAABn..." (URL-safe, 4-hour TTL) decode_path("gAAAAABn...") # => "/opt/immich/paid/creator/file.mp4" """ import base64 from pathlib import Path from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes from fastapi import HTTPException def _get_fernet() -> Fernet: """Derive a Fernet key from .session_secret via HKDF (cached after first call).""" if not hasattr(_get_fernet, "_instance"): from ..core.config import settings secret_path = settings.PROJECT_ROOT / ".session_secret" secret = secret_path.read_text().strip().encode() derived = HKDF( algorithm=hashes.SHA256(), length=32, salt=b"path-token-v1", info=b"fernet-key", ).derive(secret) key = base64.urlsafe_b64encode(derived) _get_fernet._instance = Fernet(key) return _get_fernet._instance def encode_path(file_path: str) -> str: """Encrypt a file path into a URL-safe Fernet token (4-hour TTL).""" f = _get_fernet() return f.encrypt(file_path.encode()).decode() def decode_path(token: str) -> str: """Decrypt a Fernet token back to a file path. Raises HTTP 400 on invalid or expired tokens (TTL = 4 hours). """ f = _get_fernet() try: return f.decrypt(token.encode(), ttl=14400).decode() except InvalidToken: raise HTTPException(status_code=400, detail="Invalid or expired file token")