|
@@ -0,0 +1,666 @@
|
|
|
|
|
+"""
|
|
|
|
|
+Orca Cloud API Routes
|
|
|
|
|
+
|
|
|
|
|
+PKCE-based connect/disconnect + profile sync endpoints for the
|
|
|
|
|
+Orca Cloud (Supabase) profile-sync surface.
|
|
|
|
|
+
|
|
|
|
|
+Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
|
|
|
|
|
+
|
|
|
|
|
+ POST /orca-cloud/auth/start
|
|
|
|
|
+ Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
|
|
|
|
|
+ POST /orca-cloud/auth/finish
|
|
|
|
|
+ Parse the pasted callback URL, validate state for CSRF, exchange the
|
|
|
|
|
+ code for tokens, persist them atomically.
|
|
|
|
|
+ GET /orca-cloud/status
|
|
|
|
|
+ Connected/disconnected + email + user_id.
|
|
|
|
|
+ POST /orca-cloud/logout
|
|
|
|
|
+ Clear stored tokens (no Supabase-side revocation — token still
|
|
|
|
|
+ survives until its 1h expiry, but Bambuddy has no way to use it).
|
|
|
|
|
+ GET /orca-cloud/profiles
|
|
|
|
|
+ Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
|
|
|
|
|
+ access token if it's within the 5-min leeway of expiry.
|
|
|
|
|
+ GET /orca-cloud/profiles/{id}
|
|
|
|
|
+ Single profile's full content.
|
|
|
|
|
+
|
|
|
|
|
+Storage shape mirrors the Bambu Cloud surface: per-user columns on
|
|
|
|
|
+``users`` when auth is enabled, fallback to global ``settings`` keys when
|
|
|
|
|
+auth is disabled. The transient PKCE state (verifier, state, pending_at)
|
|
|
|
|
+is stored alongside the tokens — same dual-mode pattern.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import logging
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
+
|
|
|
|
|
+from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
+from sqlalchemy import select, update
|
|
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller
|
|
|
|
|
+from backend.app.core.database import get_db
|
|
|
|
|
+from backend.app.core.permissions import Permission
|
|
|
|
|
+from backend.app.models.settings import Settings
|
|
|
|
|
+from backend.app.models.user import User
|
|
|
|
|
+from backend.app.schemas.orca_cloud import (
|
|
|
|
|
+ OrcaAuthFinishRequest,
|
|
|
|
|
+ OrcaAuthPasswordRequest,
|
|
|
|
|
+ OrcaAuthStartRequest,
|
|
|
|
|
+ OrcaAuthStartResponse,
|
|
|
|
|
+ OrcaAuthStatusResponse,
|
|
|
|
|
+ OrcaProfileDetail,
|
|
|
|
|
+ OrcaProfileListResponse,
|
|
|
|
|
+ OrcaProfileMeta,
|
|
|
|
|
+)
|
|
|
|
|
+from backend.app.services.orca_cloud import (
|
|
|
|
|
+ PENDING_PKCE_TTL,
|
|
|
|
|
+ OrcaCloudAuthError,
|
|
|
|
|
+ OrcaCloudError,
|
|
|
|
|
+ OrcaCloudService,
|
|
|
|
|
+ build_authorize_url,
|
|
|
|
|
+ generate_pkce,
|
|
|
|
|
+ parse_callback_url,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+# Router-level dependency: enforce the same API-key cloud-access fence as the
|
|
|
|
|
+# Bambu Cloud router (rejects ownerless legacy keys, requires the
|
|
|
|
|
+# ``can_access_cloud`` scope, stashes the owner on ``request.state`` so
|
|
|
|
|
+# per-route deps can resolve it as the effective ``current_user``).
|
|
|
|
|
+# Without this gate the kiosk's API-keyed requests sail past with
|
|
|
|
|
+# ``current_user=None`` → ``_build_authenticated_service`` falls back to
|
|
|
|
|
+# the global Settings table → no Orca token → 401, no presets surfaced.
|
|
|
|
|
+# Bambu Cloud works in the same kiosk because its router has this gate.
|
|
|
|
|
+router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)])
|
|
|
|
|
+
|
|
|
|
|
+# Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary.
|
|
|
|
|
+# Empirically (confirmed against a live account on 2026-06-04): Orca uses
|
|
|
|
|
+# ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio
|
|
|
|
|
+# ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere
|
|
|
|
|
+# in the OrcaSlicer source. The aliases keep us forward-compatible if Orca
|
|
|
|
|
+# ever flips back to the older naming.
|
|
|
|
|
+_ORCA_TYPE_TO_BAMBU = {
|
|
|
|
|
+ "filament": "filament",
|
|
|
|
|
+ "printer": "printer",
|
|
|
|
|
+ "machine": "printer", # alias for the BambuStudio-style naming
|
|
|
|
|
+ "print": "process",
|
|
|
|
|
+ "process": "process", # alias for the BambuStudio-style naming
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
|
|
|
|
|
+ """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
|
|
|
|
|
+ into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
|
|
|
|
|
+ isn't a dict or the type isn't one we render."""
|
|
|
|
|
+ content = orca_profile.get("content") or {}
|
|
|
|
|
+ if not isinstance(content, dict):
|
|
|
|
|
+ return None
|
|
|
|
|
+ bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
|
|
|
|
|
+ if bambu_type is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ pid = orca_profile.get("id")
|
|
|
|
|
+ if pid is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ updated = orca_profile.get("updated_time")
|
|
|
|
|
+ return OrcaProfileMeta(
|
|
|
|
|
+ setting_id=str(pid),
|
|
|
|
|
+ name=str(orca_profile.get("name") or pid),
|
|
|
|
|
+ type=bambu_type,
|
|
|
|
|
+ version=_str_or_none(content.get("version")),
|
|
|
|
|
+ # ``from`` distinguishes ``system`` (bundled) from ``User`` (custom),
|
|
|
|
|
+ # same field the Bambu source-of-truth uses for that distinction.
|
|
|
|
|
+ user_id=_str_or_none(content.get("user_id") or content.get("from")),
|
|
|
|
|
+ updated_time=str(updated) if updated is not None else None,
|
|
|
|
|
+ # Every profile that lives in the user's Orca Cloud account is by
|
|
|
|
|
+ # definition user-authored; bundled defaults aren't synced.
|
|
|
|
|
+ is_custom=True,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _str_or_none(value: object) -> str | None:
|
|
|
|
|
+ """Cast non-empty scalars to ``str``; pass ``None`` and empty values
|
|
|
|
|
+ through unchanged. Used to keep the response shape consistent when
|
|
|
|
|
+ Orca's source data has heterogenous typing for the same field."""
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ s = str(value)
|
|
|
|
|
+ return s if s else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
|
|
|
|
|
+# pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
|
|
|
|
|
+# settings table see a consistent prefix.
|
|
|
|
|
+_SETTINGS_KEYS = {
|
|
|
|
|
+ "token": "orca_cloud_token",
|
|
|
|
|
+ "refresh_token": "orca_cloud_refresh_token",
|
|
|
|
|
+ "expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string
|
|
|
|
|
+ "email": "orca_cloud_email",
|
|
|
|
|
+ "user_id": "orca_cloud_user_id",
|
|
|
|
|
+ "pending_verifier": "orca_cloud_pending_verifier",
|
|
|
|
|
+ "pending_state": "orca_cloud_pending_state",
|
|
|
|
|
+ "pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Storage helpers — bridge User-row vs Settings-table fallback transparently
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _iso(dt: datetime | None) -> str | None:
|
|
|
|
|
+ """Serialize a datetime to ISO 8601 UTC. ``None`` passes through."""
|
|
|
|
|
+ if dt is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if dt.tzinfo is None:
|
|
|
|
|
+ dt = dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
+ return dt.astimezone(timezone.utc).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _as_utc(dt: datetime | None) -> datetime | None:
|
|
|
|
|
+ """Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as
|
|
|
|
|
+ UTC. ``None`` passes through. Already-aware datetimes are converted to
|
|
|
|
|
+ UTC to normalize."""
|
|
|
|
|
+ if dt is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if dt.tzinfo is None:
|
|
|
|
|
+ return dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
+ return dt.astimezone(timezone.utc)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _parse_iso(value: str | None) -> datetime | None:
|
|
|
|
|
+ """Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through."""
|
|
|
|
|
+ if not value:
|
|
|
|
|
+ return None
|
|
|
|
|
+ try:
|
|
|
|
|
+ dt = datetime.fromisoformat(value)
|
|
|
|
|
+ except (TypeError, ValueError):
|
|
|
|
|
+ return None
|
|
|
|
|
+ if dt.tzinfo is None:
|
|
|
|
|
+ dt = dt.replace(tzinfo=timezone.utc)
|
|
|
|
|
+ return dt
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class _OrcaCredentials:
|
|
|
|
|
+ """Lightweight bag for stored Orca Cloud credentials. We use a class
|
|
|
|
|
+ rather than a dataclass so the helpers can mutate it as needed during
|
|
|
|
|
+ JIT-refresh without rebuilding the whole object."""
|
|
|
|
|
+
|
|
|
|
|
+ __slots__ = (
|
|
|
|
|
+ "token",
|
|
|
|
|
+ "refresh_token",
|
|
|
|
|
+ "expires_at",
|
|
|
|
|
+ "email",
|
|
|
|
|
+ "user_id",
|
|
|
|
|
+ "pending_verifier",
|
|
|
|
|
+ "pending_state",
|
|
|
|
|
+ "pending_at",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self) -> None:
|
|
|
|
|
+ self.token: str | None = None
|
|
|
|
|
+ self.refresh_token: str | None = None
|
|
|
|
|
+ self.expires_at: datetime | None = None
|
|
|
|
|
+ self.email: str | None = None
|
|
|
|
|
+ self.user_id: str | None = None
|
|
|
|
|
+ self.pending_verifier: str | None = None
|
|
|
|
|
+ self.pending_state: str | None = None
|
|
|
|
|
+ self.pending_at: datetime | None = None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials:
|
|
|
|
|
+ """Load stored Orca Cloud credentials for the caller (user-row when auth
|
|
|
|
|
+ is enabled, Settings fallback when auth is disabled).
|
|
|
|
|
+
|
|
|
|
|
+ Datetimes coming back from the User row are NAIVE on the Postgres side
|
|
|
|
|
+ (asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but
|
|
|
|
|
+ represent UTC moments because that's what we stored. We attach
|
|
|
|
|
+ ``tzinfo=UTC`` here so downstream comparisons against
|
|
|
|
|
+ ``datetime.now(timezone.utc)`` don't get shifted by the host's local
|
|
|
|
|
+ offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on
|
|
|
|
|
+ a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and
|
|
|
|
|
+ fires the 10-minute TTL guard immediately."""
|
|
|
|
|
+ creds = _OrcaCredentials()
|
|
|
|
|
+ if user is not None:
|
|
|
|
|
+ creds.token = user.orca_cloud_token
|
|
|
|
|
+ creds.refresh_token = user.orca_cloud_refresh_token
|
|
|
|
|
+ creds.expires_at = _as_utc(user.orca_cloud_expires_at)
|
|
|
|
|
+ creds.email = user.orca_cloud_email
|
|
|
|
|
+ creds.user_id = user.orca_cloud_user_id
|
|
|
|
|
+ creds.pending_verifier = user.orca_cloud_pending_verifier
|
|
|
|
|
+ creds.pending_state = user.orca_cloud_pending_state
|
|
|
|
|
+ creds.pending_at = _as_utc(user.orca_cloud_pending_at)
|
|
|
|
|
+ return creds
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
|
|
|
|
|
+ raw = {s.key: s.value for s in result.scalars().all()}
|
|
|
|
|
+ creds.token = raw.get(_SETTINGS_KEYS["token"])
|
|
|
|
|
+ creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"])
|
|
|
|
|
+ creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
|
|
|
|
|
+ creds.email = raw.get(_SETTINGS_KEYS["email"])
|
|
|
|
|
+ creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
|
|
|
|
|
+ creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
|
|
|
|
|
+ creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
|
|
|
|
|
+ creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
|
|
|
|
|
+ return creds
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _persist_pending_pkce(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ user: User | None,
|
|
|
|
|
+ verifier: str,
|
|
|
|
|
+ state: str,
|
|
|
|
|
+ when: datetime,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
|
|
|
|
|
+ if user is not None:
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ update(User)
|
|
|
|
|
+ .where(User.id == user.id)
|
|
|
|
|
+ .values(
|
|
|
|
|
+ orca_cloud_pending_verifier=verifier,
|
|
|
|
|
+ orca_cloud_pending_state=state,
|
|
|
|
|
+ orca_cloud_pending_at=when,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return
|
|
|
|
|
+ await _upsert_settings(
|
|
|
|
|
+ db,
|
|
|
|
|
+ {
|
|
|
|
|
+ _SETTINGS_KEYS["pending_verifier"]: verifier,
|
|
|
|
|
+ _SETTINGS_KEYS["pending_state"]: state,
|
|
|
|
|
+ _SETTINGS_KEYS["pending_at"]: _iso(when),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _persist_tokens(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ user: User | None,
|
|
|
|
|
+ access_token: str,
|
|
|
|
|
+ refresh_token: str | None,
|
|
|
|
|
+ expires_at: datetime | None,
|
|
|
|
|
+ email: str | None,
|
|
|
|
|
+ user_id: str | None,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Atomically write the new access/refresh pair to whichever backing store
|
|
|
|
|
+ the deployment uses. Also clears the pending PKCE state on the same write,
|
|
|
|
|
+ since by this point the handshake is complete."""
|
|
|
|
|
+ if user is not None:
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ update(User)
|
|
|
|
|
+ .where(User.id == user.id)
|
|
|
|
|
+ .values(
|
|
|
|
|
+ orca_cloud_token=access_token,
|
|
|
|
|
+ orca_cloud_refresh_token=refresh_token,
|
|
|
|
|
+ orca_cloud_expires_at=expires_at,
|
|
|
|
|
+ orca_cloud_email=email,
|
|
|
|
|
+ orca_cloud_user_id=user_id,
|
|
|
|
|
+ orca_cloud_pending_verifier=None,
|
|
|
|
|
+ orca_cloud_pending_state=None,
|
|
|
|
|
+ orca_cloud_pending_at=None,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return
|
|
|
|
|
+ await _upsert_settings(
|
|
|
|
|
+ db,
|
|
|
|
|
+ {
|
|
|
|
|
+ _SETTINGS_KEYS["token"]: access_token,
|
|
|
|
|
+ _SETTINGS_KEYS["refresh_token"]: refresh_token,
|
|
|
|
|
+ _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
|
|
|
|
|
+ _SETTINGS_KEYS["email"]: email,
|
|
|
|
|
+ _SETTINGS_KEYS["user_id"]: user_id,
|
|
|
|
|
+ _SETTINGS_KEYS["pending_verifier"]: None,
|
|
|
|
|
+ _SETTINGS_KEYS["pending_state"]: None,
|
|
|
|
|
+ _SETTINGS_KEYS["pending_at"]: None,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _persist_rotated_tokens(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ user: User | None,
|
|
|
|
|
+ access_token: str,
|
|
|
|
|
+ refresh_token: str | None,
|
|
|
|
|
+ expires_at: datetime | None,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Persist tokens after a refresh — does NOT touch email/user_id and does
|
|
|
|
|
+ NOT touch the pending PKCE state (refresh happens long after the handshake)."""
|
|
|
|
|
+ if user is not None:
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ update(User)
|
|
|
|
|
+ .where(User.id == user.id)
|
|
|
|
|
+ .values(
|
|
|
|
|
+ orca_cloud_token=access_token,
|
|
|
|
|
+ orca_cloud_refresh_token=refresh_token,
|
|
|
|
|
+ orca_cloud_expires_at=expires_at,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return
|
|
|
|
|
+ await _upsert_settings(
|
|
|
|
|
+ db,
|
|
|
|
|
+ {
|
|
|
|
|
+ _SETTINGS_KEYS["token"]: access_token,
|
|
|
|
|
+ _SETTINGS_KEYS["refresh_token"]: refresh_token,
|
|
|
|
|
+ _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _clear_credentials(db: AsyncSession, user: User | None) -> None:
|
|
|
|
|
+ """Wipe everything Orca-related (tokens, identity, pending state)."""
|
|
|
|
|
+ if user is not None:
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ update(User)
|
|
|
|
|
+ .where(User.id == user.id)
|
|
|
|
|
+ .values(
|
|
|
|
|
+ orca_cloud_token=None,
|
|
|
|
|
+ orca_cloud_refresh_token=None,
|
|
|
|
|
+ orca_cloud_expires_at=None,
|
|
|
|
|
+ orca_cloud_email=None,
|
|
|
|
|
+ orca_cloud_user_id=None,
|
|
|
|
|
+ orca_cloud_pending_verifier=None,
|
|
|
|
|
+ orca_cloud_pending_state=None,
|
|
|
|
|
+ orca_cloud_pending_at=None,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return
|
|
|
|
|
+ result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
|
|
|
|
|
+ for setting in result.scalars().all():
|
|
|
|
|
+ await db.delete(setting)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None:
|
|
|
|
|
+ """Idempotent upsert into the Settings table. ``None`` values delete the row."""
|
|
|
|
|
+ keys = [k for k, _ in values.items()]
|
|
|
|
|
+ result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
|
|
|
|
|
+ existing = {s.key: s for s in result.scalars().all()}
|
|
|
|
|
+ for key, value in values.items():
|
|
|
|
|
+ row = existing.get(key)
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ if row is not None:
|
|
|
|
|
+ await db.delete(row)
|
|
|
|
|
+ continue
|
|
|
|
|
+ if row is not None:
|
|
|
|
|
+ row.value = value
|
|
|
|
|
+ else:
|
|
|
|
|
+ db.add(Settings(key=key, value=value))
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Authenticated service builder with JIT refresh
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _build_authenticated_service(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ user: User | None,
|
|
|
|
|
+) -> OrcaCloudService:
|
|
|
|
|
+ """Construct an :class:`OrcaCloudService` pre-populated with stored
|
|
|
|
|
+ credentials. If the access token is within the refresh-leeway of expiry,
|
|
|
|
|
+ proactively refresh and persist the new pair BEFORE returning, so the
|
|
|
|
|
+ next API call doesn't time out mid-flight on an expired token."""
|
|
|
|
|
+ creds = await _load_credentials(db, user)
|
|
|
|
|
+ if not creds.token:
|
|
|
|
|
+ raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
|
|
|
|
|
+
|
|
|
|
|
+ svc = OrcaCloudService()
|
|
|
|
|
+ svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
|
|
|
|
|
+ if not svc.is_authenticated:
|
|
|
|
|
+ if not svc.refresh_token:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=401,
|
|
|
|
|
+ detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ await svc.refresh()
|
|
|
|
|
+ except OrcaCloudAuthError as e:
|
|
|
|
|
+ # Refresh token was revoked or rotated out from under us. Clear
|
|
|
|
|
+ # the stale credentials so the UI flips to disconnected.
|
|
|
|
|
+ await _clear_credentials(db, user)
|
|
|
|
|
+ raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
|
|
|
|
+ # Persist new pair BEFORE returning. A crash between here and the
|
|
|
|
|
+ # downstream API call would still leave the user with valid stored
|
|
|
|
|
+ # tokens for the next request.
|
|
|
|
|
+ await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
|
|
|
|
|
+ return svc
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Route handlers
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/auth/start", response_model=OrcaAuthStartResponse)
|
|
|
|
|
+async def auth_start(
|
|
|
|
|
+ payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Generate PKCE state and return the Supabase authorize URL for the
|
|
|
|
|
+ requested OAuth provider (google / apple / github). The frontend opens
|
|
|
|
|
+ the URL in a new tab; after sign-in the user pastes the callback URL
|
|
|
|
|
+ back into ``/auth/finish``.
|
|
|
|
|
+
|
|
|
|
|
+ ``state`` is generated but NOT sent to Supabase (it would clash with
|
|
|
|
|
+ GoTrue's internal redirect_to-tracking state). We still persist it so
|
|
|
|
|
+ a future flow change can re-introduce state-based CSRF if needed; CSRF
|
|
|
|
|
+ protection today comes from the PKCE verifier itself, which is
|
|
|
|
|
+ single-use, server-side, and bound to the caller's user row."""
|
|
|
|
|
+ verifier, challenge, state = generate_pkce()
|
|
|
|
|
+ await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
|
|
|
|
|
+ return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
|
|
|
|
|
+async def auth_password(
|
|
|
|
|
+ payload: OrcaAuthPasswordRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Direct email+password sign-in. No browser redirect, no paste flow —
|
|
|
|
|
+ Bambuddy POSTs the credentials to Supabase and stores the returned
|
|
|
|
|
+ tokens. Whether this succeeds depends on Orca's Supabase project
|
|
|
|
|
+ accepting the password grant; if it rejects (the SDK refuses passwords
|
|
|
|
|
+ by design, the backend may follow suit), the caller falls back to an
|
|
|
|
|
+ OAuth provider via ``/auth/start``."""
|
|
|
|
|
+ svc = OrcaCloudService()
|
|
|
|
|
+ try:
|
|
|
|
|
+ await svc.password_login(payload.email, payload.password)
|
|
|
|
|
+ except OrcaCloudAuthError as e:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
|
|
|
|
+
|
|
|
|
|
+ email: str | None = None
|
|
|
|
|
+ user_id: str | None = None
|
|
|
|
|
+ try:
|
|
|
|
|
+ user_info = await svc.get_user_info()
|
|
|
|
|
+ if isinstance(user_info, dict):
|
|
|
|
|
+ email = user_info.get("email")
|
|
|
|
|
+ user_id = user_info.get("id")
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
|
|
|
|
|
+
|
|
|
|
|
+ await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
|
|
|
|
|
+ return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
|
|
|
|
|
+async def auth_finish(
|
|
|
|
|
+ payload: OrcaAuthFinishRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Complete the PKCE handshake — parse the pasted callback URL, validate
|
|
|
|
|
+ state (CSRF), exchange the code for tokens, persist."""
|
|
|
|
|
+ creds = await _load_credentials(db, current_user)
|
|
|
|
|
+ if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=400,
|
|
|
|
|
+ detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
|
|
|
|
|
+ # normalization. Subtracting two aware UTC datetimes gives a real wall-clock
|
|
|
|
|
+ # delta with no local-offset shift.
|
|
|
|
|
+ age = datetime.now(timezone.utc) - creds.pending_at
|
|
|
|
|
+ if age > PENDING_PKCE_TTL:
|
|
|
|
|
+ # Don't leave the stale state in the DB — clear it so the user has to
|
|
|
|
|
+ # restart fresh, which forces a new verifier/state pair.
|
|
|
|
|
+ await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=400,
|
|
|
|
|
+ detail=(
|
|
|
|
|
+ f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
|
|
|
|
|
+ "Click Connect again to start over."
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ code, _callback_state = parse_callback_url(payload.callback_url)
|
|
|
|
|
+ if not code:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=400,
|
|
|
|
|
+ detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
|
|
|
|
|
+ )
|
|
|
|
|
+ # We do NOT validate ``state`` here: Supabase doesn't echo back a state we
|
|
|
|
|
+ # don't send (see :func:`build_authorize_url` for why we can't send one).
|
|
|
|
|
+ # CSRF is protected by PKCE: the verifier is server-side and single-use,
|
|
|
|
|
+ # so an attacker can't complete the exchange with a code they obtained
|
|
|
|
|
+ # separately. ``pending_state`` is still stored for forward compatibility
|
|
|
|
|
+ # if Supabase ever supports a client-passed state alongside redirect_to.
|
|
|
|
|
+
|
|
|
|
|
+ svc = OrcaCloudService()
|
|
|
|
|
+ try:
|
|
|
|
|
+ await svc.exchange_code(code, creds.pending_verifier)
|
|
|
|
|
+ except OrcaCloudAuthError as e:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
|
|
|
|
+
|
|
|
|
|
+ # Fetch user info so we can show the connected email in the UI.
|
|
|
|
|
+ email: str | None = None
|
|
|
|
|
+ user_id: str | None = None
|
|
|
|
|
+ try:
|
|
|
|
|
+ user_info = await svc.get_user_info()
|
|
|
|
|
+ if isinstance(user_info, dict):
|
|
|
|
|
+ email = user_info.get("email")
|
|
|
|
|
+ user_id = user_info.get("id")
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ # Don't fail the whole connect flow just because the user-info side
|
|
|
|
|
+ # call hiccuped — we have valid tokens, that's the load-bearing part.
|
|
|
|
|
+ logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
|
|
|
|
|
+
|
|
|
|
|
+ await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
|
|
|
|
|
+ return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/status", response_model=OrcaAuthStatusResponse)
|
|
|
|
|
+async def get_status(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return whether the caller has an Orca Cloud session stored, plus
|
|
|
|
|
+ identifier details for display. Does NOT make a live API call."""
|
|
|
|
|
+ creds = await _load_credentials(db, current_user)
|
|
|
|
|
+ return OrcaAuthStatusResponse(
|
|
|
|
|
+ connected=bool(creds.token),
|
|
|
|
|
+ email=creds.email,
|
|
|
|
|
+ user_id=creds.user_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/logout")
|
|
|
|
|
+async def logout(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Clear stored Orca Cloud credentials. Does not call Supabase's
|
|
|
|
|
+ ``/logout`` endpoint (the token would still survive its 1h expiry there
|
|
|
|
|
+ either way, and Bambuddy will no longer have it to use)."""
|
|
|
|
|
+ await _clear_credentials(db, current_user)
|
|
|
|
|
+ return {"success": True}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/profiles", response_model=OrcaProfileListResponse)
|
|
|
|
|
+async def list_profiles(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return profile metadata grouped by type (``filament`` / ``printer``
|
|
|
|
|
+ / ``process``), matching the ``SlicerSettingsResponse`` shape the
|
|
|
|
|
+ Bambu Cloud tab consumes. This lets the frontend render Orca profiles
|
|
|
|
|
+ with the same visual components — same cards, same filter bar, same
|
|
|
|
|
+ grouping — without separate UI code paths."""
|
|
|
|
|
+ svc = await _build_authenticated_service(db, current_user)
|
|
|
|
|
+ try:
|
|
|
|
|
+ raw_profiles = await svc.list_profiles()
|
|
|
|
|
+ except OrcaCloudAuthError as e:
|
|
|
|
|
+ raise HTTPException(status_code=401, detail=str(e)) from e
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ raise HTTPException(status_code=502, detail=str(e)) from e
|
|
|
|
|
+ grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []}
|
|
|
|
|
+ # Log any unknown content.type values we silently drop, so a future
|
|
|
|
|
+ # change in Orca's type vocabulary surfaces in the logs rather than
|
|
|
|
|
+ # quietly losing profiles.
|
|
|
|
|
+ unknown_types: dict[str, int] = {}
|
|
|
|
|
+ for entry in raw_profiles:
|
|
|
|
|
+ setting = _orca_to_setting(entry)
|
|
|
|
|
+ if setting is None:
|
|
|
|
|
+ content = entry.get("content") if isinstance(entry, dict) else None
|
|
|
|
|
+ raw_type = (content.get("type") if isinstance(content, dict) else None) or "<missing>"
|
|
|
|
|
+ unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1
|
|
|
|
|
+ continue
|
|
|
|
|
+ grouped[setting.type].append(setting)
|
|
|
|
|
+ if unknown_types:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s",
|
|
|
|
|
+ sum(unknown_types.values()),
|
|
|
|
|
+ unknown_types,
|
|
|
|
|
+ )
|
|
|
|
|
+ return OrcaProfileListResponse(**grouped)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
|
|
|
|
|
+async def get_profile(
|
|
|
|
|
+ profile_id: str,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
|
|
|
|
+):
|
|
|
|
|
+ """Fetch a single profile's full content, shaped like
|
|
|
|
|
+ ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
|
|
|
|
|
+ unchanged. The inner ``setting`` field is the raw slicer-format JSON
|
|
|
|
|
+ Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
|
|
|
|
|
+ BambuStudio fork."""
|
|
|
|
|
+ svc = await _build_authenticated_service(db, current_user)
|
|
|
|
|
+ try:
|
|
|
|
|
+ profile = await svc.get_profile(profile_id)
|
|
|
|
|
+ except OrcaCloudAuthError as e:
|
|
|
|
|
+ raise HTTPException(status_code=401, detail=str(e)) from e
|
|
|
|
|
+ except OrcaCloudError as e:
|
|
|
|
|
+ if "not found" in str(e).lower():
|
|
|
|
|
+ raise HTTPException(status_code=404, detail=str(e)) from e
|
|
|
|
|
+ raise HTTPException(status_code=502, detail=str(e)) from e
|
|
|
|
|
+ content = profile.get("content") if isinstance(profile, dict) else None
|
|
|
|
|
+ if not isinstance(content, dict):
|
|
|
|
|
+ content = {}
|
|
|
|
|
+ orca_type = str(content.get("type", ""))
|
|
|
|
|
+ bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type)
|
|
|
|
|
+ update_time = profile.get("updated_time") if isinstance(profile, dict) else None
|
|
|
|
|
+ return OrcaProfileDetail(
|
|
|
|
|
+ setting_id=str(profile_id),
|
|
|
|
|
+ name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id),
|
|
|
|
|
+ type=bambu_type,
|
|
|
|
|
+ version=_str_or_none(content.get("version")),
|
|
|
|
|
+ base_id=_str_or_none(content.get("inherits") or content.get("base_id")),
|
|
|
|
|
+ update_time=str(update_time) if update_time is not None else None,
|
|
|
|
|
+ setting=content,
|
|
|
|
|
+ )
|