Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,20 @@ CORS_ORIGIN=
# over /api/config, and the frontend container's CSP connect-src, so one setting.
NOTES_BASE_URL=https://notes.geckopico.com

# The media library. Read from M1 on.
# The media library. GVC's server searches it and uploads to it as the signed-in user,
# and the browser loads thumbnails and previews from it (the CSP's img-src/media-src).
# GAM must share JWT_SECRET_KEY with Notes and GVC, or it refuses the forwarded session.
#
# In development: GAM_BASE_URL=http://localhost:8001 and NOTES_BASE_URL=http://localhost:8000.
# The dev user (DEV_AUTH_USER) sends no token, so GAM must run with a dev user too.
GAM_BASE_URL=https://gam.geckopico.com

# Per-request timeout for GAM, in seconds.
GAM_TIMEOUT_SECONDS=30

# The largest file "Copy into GAM" will move out of a note pushed from Notes, in MB.
NOTE_MEDIA_MAX_MB=4096

# ─── Administrators ──────────────────────────────────────────────────────────

# Gecko Notes user ids or usernames, comma-separated, matched exactly. Nothing in GVC
Expand Down
24 changes: 24 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,30 @@ def current_user(
CurrentUser = Annotated[UserCtx, Depends(current_user)]


def caller_token(
authorization: Annotated[Optional[str], Header()] = None,
gecko_session: Annotated[Optional[str], Cookie()] = None,
) -> Optional[str]:
"""The raw token the caller signed in with, to call GAM as them.

GAM verifies the same Notes-issued JWT, so forwarding it lets GVC search and upload
as the user, with GAM's own per-user ownership intact and no service account to
hold. Same transport order as `current_user` (header, then cookie). Only meaningful
on a route that also depends on `current_user`, which has already verified it and
applied the cookie path's origin check.

None under the dev user: GAM then has to be running with a dev user of its own.
"""
if authorization and authorization.startswith("Bearer "):
return authorization[7:].strip() or None
if gecko_session:
return gecko_session.strip() or None
return None


CallerToken = Annotated[Optional[str], Depends(caller_token)]


# ─── administrators ──────────────────────────────────────────────────────────
#
# The token carries no admin claim and `User.is_admin` has no writer, so an admin is
Expand Down
10 changes: 8 additions & 2 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,15 @@ class Settings(BaseSettings):
database_url: str = ""

# ─── siblings ────────────────────────────────────────────────────────────
# The media library. Read from M1 on; set now so a deployment's .env is complete
# before anything depends on it.
# The media library: footage, pictures and music are searched for and kept there.
gam_base_url: str = "https://gam.geckopico.com"
# Per request to GAM. Generous, because a search that runs its semantic half calls
# an embedding provider before it answers.
gam_timeout_seconds: float = 30
# The largest file "Copy into GAM" will move out of a pushed note. Streamed to a
# temp file and checked as it arrives, not after: a size checked afterwards has
# already filled the disk.
note_media_max_mb: int = 4096

# ─── http ────────────────────────────────────────────────────────────────
cors_origin: str = ""
Expand Down
1 change: 1 addition & 0 deletions backend/app/gam/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""GAM, the suite's media library, as GVC sees it."""
156 changes: 156 additions & 0 deletions backend/app/gam/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Calling GAM as the signed-in user.

GVC forwards the caller's own Notes-issued token, so GAM sees the same user it would
see from its own frontend: same library, same ownership checks. No service account,
and nothing GVC could reach that the user could not.

Every failure becomes a `GamError` with a code, which `main.py` turns into a 502 in the
suite's error shape. A 502 rather than passing GAM's status through: from the browser's
point of view GVC is the server, and GVC's upstream failed.
"""

from __future__ import annotations

import logging
from typing import IO, Any, Dict, Optional

import httpx

from app import outbound
from app.config import settings
from app.schemas_gam import GamAssetRead, GamHit, GamSearch

logger = logging.getLogger(__name__)


class GamError(Exception):
def __init__(self, code: str, message: str, *, status_code: int = 502):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code


def _absolute(url: Optional[str]) -> Optional[str]:
"""GAM's media URLs are relative to GAM (`/media/<key>?exp=…&sig=…`)."""
if not url:
return None
if url.startswith("http://") or url.startswith("https://"):
return url
return settings.gam_base_url.rstrip("/") + url


def to_asset(raw: Dict[str, Any]) -> GamAssetRead:
return GamAssetRead(
id=raw["id"],
name=raw.get("name") or "",
asset_type=raw.get("asset_type") or "",
duration_seconds=raw.get("duration_seconds"),
width=raw.get("width"),
height=raw.get("height"),
file_url=_absolute(raw.get("file_url")),
thumb_url=_absolute(raw.get("thumb_url")),
credit=raw.get("credit") or "",
parent_asset_id=raw.get("parent_asset_id"),
in_point=raw.get("in_point"),
out_point=raw.get("out_point"),
missing=bool(raw.get("missing")),
)


class GamClient:
def __init__(self, token: Optional[str]):
headers = {"Authorization": f"Bearer {token}"} if token else {}
self._http = outbound.client(
settings.gam_base_url, timeout=settings.gam_timeout_seconds, headers=headers
)

def close(self) -> None:
self._http.close()

def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
try:
response = self._http.request(method, path, **kwargs)
except httpx.TimeoutException as exc:
raise GamError("gam_unavailable", "The asset library took too long to answer") from exc
except httpx.HTTPError as exc:
logger.warning("GAM unreachable at %s: %s", settings.gam_base_url, exc)
raise GamError("gam_unavailable", "Could not reach the asset library") from exc

if response.status_code == 401:
# The user is signed in here, so GAM refusing the same token means the two
# apps disagree about JWT_SECRET_KEY: an operator's problem, said as one.
raise GamError(
"gam_rejected_session",
"The asset library did not accept your session. GVC and GAM probably "
"have different JWT_SECRET_KEY values.",
)
if response.status_code >= 500:
raise GamError("gam_unavailable", f"The asset library failed ({response.status_code})")
return response

@staticmethod
def _detail(response: httpx.Response) -> tuple[str, str]:
try:
detail = response.json().get("detail") or {}
except ValueError:
detail = {}
if isinstance(detail, dict):
return detail.get("code") or "gam_error", detail.get("message") or response.text[:200]
return "gam_error", str(detail)

def search(self, query: str, *, asset_type: Optional[str] = None, limit: int = 30) -> GamSearch:
params: Dict[str, Any] = {"q": query, "limit": limit}
if asset_type:
params["asset_type"] = asset_type
response = self._request("GET", "/api/search", params=params)
if response.status_code != 200:
code, message = self._detail(response)
raise GamError(code, message, status_code=400 if response.status_code < 500 else 502)
body = response.json()
return GamSearch(
data=[
GamHit(
asset=to_asset(hit["asset"]),
score=hit.get("score") or 0.0,
snippet=hit.get("snippet") or "",
start_time=hit.get("start_time"),
# Absent from a GAM older than its `end_time` change (gam#36); the
# editor then sizes the moment itself.
end_time=hit.get("end_time"),
)
for hit in body.get("data", [])
],
total=body.get("total", 0),
query=body.get("query", query),
semantic=bool(body.get("semantic")),
semantic_error=body.get("semantic_error"),
)

def get_asset(self, asset_id: str) -> Optional[GamAssetRead]:
response = self._request("GET", f"/api/assets/{asset_id}")
if response.status_code == 404:
return None
if response.status_code != 200:
code, message = self._detail(response)
raise GamError(code, message)
return to_asset(response.json()["data"])

def upload(self, filename: str, stream: IO[bytes], content_type: Optional[str]) -> GamAssetRead:
"""Store one file in the user's library and return it."""
response = self._request(
"POST",
"/api/assets",
files={"files": (filename, stream, content_type or "application/octet-stream")},
)
if response.status_code not in (200, 201):
code, message = self._detail(response)
raise GamError(code, message, status_code=502)
body = response.json()
created = body.get("created") or []
if not created:
# GAM's partial success: a 201 that stored nothing names why per file.
rejected = (body.get("rejected") or [{}])[0]
raise GamError(rejected.get("code") or "gam_error",
rejected.get("message") or "The asset library did not store the file")
return to_asset(created[0])
12 changes: 12 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from app.auth import CurrentUser, is_admin
from app.config import settings
from app.database import engine, get_session
from app.gam.client import GamError
from app.routers import gam as gam_router
from app.routers import projects as projects_router
from app.routers import spec as spec_router
from app.schemas import ClientConfig, DataResponse, HealthResponse
Expand Down Expand Up @@ -76,6 +78,15 @@ async def _http_exception_handler(request, exc: StarletteHTTPException):
return JSONResponse(status_code=exc.status_code, content={"detail": detail})


@app.exception_handler(GamError)
async def _gam_error_handler(request, exc: GamError):
"""GAM failing is GVC's upstream failing: in the suite's error shape, with GAM's
own code where it gave one, so the editor can say which app is at fault."""
return JSONResponse(
status_code=exc.status_code, content={"detail": {"code": exc.code, "message": exc.message}}
)


def _default_code(status_code: int) -> str:
return {
400: "bad_request",
Expand Down Expand Up @@ -126,3 +137,4 @@ def me(user: CurrentUser, session: Session = Depends(get_session)) -> DataRespon

app.include_router(projects_router.router, prefix="/api/projects", tags=["projects"])
app.include_router(spec_router.router, prefix="/api/spec", tags=["spec"])
app.include_router(gam_router.router, prefix="/api/gam", tags=["gam"])
24 changes: 24 additions & 0 deletions backend/app/outbound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""HTTP clients for calls GVC makes to its siblings (GAM, Notes).

One place builds them, so tests can swap the transport for an `httpx.MockTransport`
and fake both siblings without a network, the way GAM fakes fal.ai.
"""

from typing import Dict, Optional

import httpx

# Tests set this; production leaves it None, which is httpx's real network transport.
TRANSPORT: Optional[httpx.BaseTransport] = None


def client(base_url: str, *, timeout: float, headers: Optional[Dict[str, str]] = None) -> httpx.Client:
return httpx.Client(
base_url=base_url.rstrip("/"),
timeout=timeout,
headers=headers or {},
transport=TRANSPORT,
# Never follow a redirect to somewhere else: the base URLs are configured, and a
# sibling that answers with a redirect is misconfigured, not something to chase.
follow_redirects=False,
)
52 changes: 52 additions & 0 deletions backend/app/routers/gam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Searching and reading GAM, the media library, as the signed-in user."""

from typing import Iterator, Literal, Optional

from fastapi import APIRouter, Depends, Query

from app.auth import CallerToken, CurrentUser
from app.gam.client import GamClient
from app.schemas import DataResponse
from app.schemas_gam import AssetLookup, AssetLookupResult, GamSearch

router = APIRouter()


def get_gam(_user: CurrentUser, token: CallerToken) -> Iterator[GamClient]:
"""A GAM client acting as the caller. Depends on `current_user` first, so a token
is only ever forwarded after this app has verified it."""
client = GamClient(token)
try:
yield client
finally:
client.close()


@router.get("/search", response_model=GamSearch)
def search(
q: str = Query(default="", max_length=500),
# What can go on screen or under it. GAM also holds documents, which a video has
# no use for, so they are not offered.
type: Optional[Literal["image", "video", "audio"]] = Query(default=None),
limit: int = Query(default=30, ge=1, le=100),
gam: GamClient = Depends(get_gam),
) -> GamSearch:
if not q.strip():
return GamSearch(data=[], total=0, query="")
result = gam.search(q, asset_type=type, limit=limit)
if type is None:
# Without a type filter GAM returns documents too; keep the three media kinds.
result.data = [h for h in result.data if h.asset.asset_type in ("image", "video", "audio")]
result.total = len(result.data)
return result


@router.post("/assets/lookup", response_model=DataResponse[AssetLookupResult])
def lookup(body: AssetLookup, gam: GamClient = Depends(get_gam)) -> DataResponse[AssetLookupResult]:
"""Names, thumbnails and fresh signed URLs for the GAM assets a spec uses.

One GET per id, in sequence: a project uses a handful, and GAM has no batch read.
Asked for on each page load, because the signed URLs expire.
"""
assets = {asset_id: gam.get_asset(asset_id) for asset_id in dict.fromkeys(body.ids)}
return DataResponse(data=AssetLookupResult(assets=assets))
28 changes: 26 additions & 2 deletions backend/app/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
from app.database import get_session
from app.models.project import Project, ProjectVersion
from app.schemas import DataResponse, ListResponse
from app.gam.client import GamClient
from app.routers.gam import get_gam
from app.schemas_projects import (
ProjectCreate, ProjectImport, ProjectRead, ProjectSummary, SpecSave, VersionDetail,
VersionRead,
CopyFailureRead, NoteMediaCopy, ProjectCreate, ProjectImport, ProjectRead,
ProjectSummary, SpecSave, VersionDetail, VersionRead,
)
from app.services import note_media
from app.services import projects as service
from app.spec.starter import starter_spec

Expand Down Expand Up @@ -146,3 +149,24 @@ def revert_project(
project = service.get_owned(session, user, project_id)
service.revert(session, project, service.get_version(session, project, version_id))
return DataResponse(data=_read(project))


@router.post("/{project_id}/copy-note-media", response_model=DataResponse[NoteMediaCopy])
def copy_note_media(
project_id: str,
user: CurrentUser,
session: Session = Depends(get_session),
gam: GamClient = Depends(get_gam),
) -> DataResponse[NoteMediaCopy]:
"""Move the files a pushed note still points at into GAM, as one new version.

Synchronous: a pushed note carries a handful of pictures and a music file. If that
proves slow it moves onto the job queue rendering brings (M2).
"""
project = service.get_owned(session, user, project_id)
result = note_media.copy_note_media(session, project, gam)
return DataResponse(data=NoteMediaCopy(
project=_read(project),
copied=result.copied,
failed=[CopyFailureRead(key=f.key, message=f.message) for f in result.failed],
))
Loading
Loading