Newer
Older
navi-1 / navi / api / routes / export.py
"""Token-gated delivery of the encrypted history snapshot.

Two endpoints, both requiring `Authorization: Bearer <NAVI_EXPORT_TOKEN>`:

* `GET /export/v1/manifest` — what is available and whether it changed. Small,
  unencrypted (it holds no message text), safe to poll every few minutes.
* `GET /export/v1/messages` — the age file itself, streamed from disk.

The fast path is the one that transfers nothing: the manifest's `content_id`
is a digest of the plaintext corpus, stable across rebuilds of unchanged data,
and the endpoint echoes it as an ETag so a conditional GET answers 304.

Auth is intentionally not the user auth stack: this is a machine credential
with no session, no cookies and no role. A wrong token and a missing one get
the same answer, so the endpoint does not confirm that a token exists.
"""

from __future__ import annotations

import hashlib
import hmac
from pathlib import Path
from typing import Annotated

import structlog
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, Response

from navi.config import settings
from navi.export.builder import EXPORT_VERSION, export_paths, read_manifest

log = structlog.get_logger()

router = APIRouter(prefix="/export/v1", tags=["export"])

_WWW_AUTH = {"WWW-Authenticate": "Bearer"}


def _digest(value: str) -> bytes:
    return hashlib.sha256(value.encode("utf-8")).digest()


def _authorize(authorization: str | None) -> None:
    expected = settings.navi_export_token.strip()
    if not expected:
        raise HTTPException(status_code=503, detail="export is not configured")
    given = ""
    if authorization and authorization.lower().startswith("bearer "):
        given = authorization[7:].strip()
    if not given or not hmac.compare_digest(_digest(given), _digest(expected)):
        raise HTTPException(status_code=401, detail="invalid token", headers=_WWW_AUTH)


def _etag(content_id: str) -> str:
    return f'"v{EXPORT_VERSION}-{content_id[:32]}"'


def _manifest_or_503() -> tuple[dict, Path]:
    manifest = read_manifest()
    age_path, _ = export_paths()
    if not manifest or not age_path.exists():
        raise HTTPException(status_code=503, detail="no export has been built yet")
    return manifest, age_path


@router.get("/manifest")
async def export_manifest(
    authorization: Annotated[str | None, Header()] = None,
) -> JSONResponse:
    """Describe the current snapshot without transferring it."""
    _authorize(authorization)
    manifest, _ = _manifest_or_503()
    log.info("export.manifest_served", content_id=manifest.get("content_id", "")[:12])
    return JSONResponse(manifest, headers={"Cache-Control": "no-store"})


@router.get("/messages")
async def export_messages(
    request: Request,
    authorization: Annotated[str | None, Header()] = None,
) -> Response:
    """The age-encrypted gzip-JSONL snapshot."""
    _authorize(authorization)
    manifest, age_path = _manifest_or_503()

    content_id = manifest.get("content_id", "")
    etag = _etag(content_id)
    if content_id and request.headers.get("if-none-match") == etag:
        return Response(status_code=304, headers={"ETag": etag})

    log.info(
        "export.file_served",
        content_id=content_id[:12],
        bytes=manifest.get("file", {}).get("bytes"),
        client=request.client.host if request.client else None,
    )
    return FileResponse(
        str(age_path),
        media_type="application/octet-stream",
        filename=manifest.get("file", {}).get("name", "messages.jsonl.gz.age"),
        headers={
            "ETag": etag,
            "Cache-Control": "no-store",
            "X-Export-Content-Id": content_id,
            "X-Export-Built-At": manifest.get("built_at", ""),
        },
    )