mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
OrcaSlicer shipped a first-class external-app pairing API (OAuth 2.0 Device Authorization Grant), so the Supabase-PKCE copy-paste flow is replaced end to end. Connecting is now: click Connect, approve a short code on the Orca Cloud settings page, done — no redirect, no callback paste, no client secret, works from a LAN IP / localhost / behind a proxy. Backend: services/orca_cloud.py rewritten to device-code request + poll (the four RFC outcomes) + refresh_token grant + introspection + external sync pull; routes expose /device/start and /device/poll (device_code kept server-side in the reused orca_cloud_pending_* columns, no migration). Requests sync:read (read-only feature). Prod endpoint by default, ORCA_CLOUD_API_BASE overrides to staging. Wired the shared httpx client (fixes a per-request socket leak). Frontend: device-code connect UI + api client methods; all 11 locales updated.
461 lines
21 KiB
Python
461 lines
21 KiB
Python
"""
|
|
Orca Cloud API Service
|
|
|
|
Handles pairing and profile sync with the Orca Cloud external-app surface.
|
|
|
|
Auth shape: OAuth 2.0 Device Authorization Grant (RFC 8628). Bambuddy is a
|
|
public client (``client_id`` only, no secret) — there is no redirect URL, so
|
|
the flow works from a LAN IP, ``localhost``, or behind a reverse proxy. The
|
|
user approves a short ``user_code`` in their Orca Cloud settings; Bambuddy
|
|
polls the token endpoint until a token pair is issued.
|
|
|
|
POST /oauth/device/code -> {device_code, user_code, verification_uri,
|
|
verification_uri_complete, expires_in, interval}
|
|
POST /oauth/token -> poll with grant_type=device_code, then later
|
|
refresh with grant_type=refresh_token
|
|
|
|
Token shape: opaque ``oc_ext_`` access token (24h) + single-use rotating
|
|
``oc_ext_rt_`` refresh token (90-day, renewed on each rotation). Reuse of a
|
|
consumed refresh token beyond a ~60s server-side grace window revokes the
|
|
whole pairing, so the route layer MUST persist the new pair atomically with
|
|
consuming the old one. Within the grace window a lost refresh race is a no-op
|
|
(each racer gets its own fresh pair), so single-flighting is hygiene, not a
|
|
correctness requirement.
|
|
|
|
API surface: ``oc_ext_`` tokens authorize ONLY the ``/api/v1/external/*``
|
|
endpoints (introspection + ``/external/sync/*``). The first-party
|
|
``/api/v1/sync/*`` surface used by the old Supabase flow is NOT reachable with
|
|
these tokens.
|
|
|
|
Cloudflare fronts ``api.orcaslicer.com`` and blocks unusual User-Agents
|
|
(``python-urllib`` gets a ``403 "error code: 1010"``); an honest
|
|
``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints + client identity (env-overridable so staging can be targeted
|
|
# without a code change). Defaults point at production.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_DEFAULT_API_BASE = "https://api.orcaslicer.com"
|
|
|
|
# Base for both the OAuth endpoints (/oauth/*) and the external API
|
|
# (/api/v1/external/*). Override with ORCA_CLOUD_API_BASE to point at
|
|
# staging (https://staging-api.orcaslicer.com) during testing.
|
|
ORCA_API_BASE = os.environ.get("ORCA_CLOUD_API_BASE", _DEFAULT_API_BASE).rstrip("/")
|
|
|
|
# Public client id registered with the Orca Cloud team (see the External App
|
|
# Pairing developer guide). Not a secret — it appears in browser-visible
|
|
# requests — but it must accompany every /oauth/device/code and /oauth/token
|
|
# call (incl. refreshes) or the server returns ``invalid_client``. Overridable
|
|
# only for the (unlikely) case of a separate staging registration.
|
|
ORCA_CLIENT_ID = os.environ.get("ORCA_CLOUD_CLIENT_ID", "oc_app_e873d49ce7dbcc7dca8ba386")
|
|
|
|
# Scope requested at pairing time. Bambuddy currently only READS the user's
|
|
# Orca Cloud profiles (list + view), so we request the minimum — read-only.
|
|
# ``sync:read`` grants pull + versions; bump to ``sync:write`` here if/when a
|
|
# push-to-cloud feature lands (which forces existing users to re-pair, since
|
|
# the granted scope is baked into the issued token).
|
|
ORCA_SCOPE = os.environ.get("ORCA_CLOUD_SCOPE", "sync:read")
|
|
|
|
# Honest client identity. Same posture as the Bambu Cloud client: identifies
|
|
# Bambuddy without impersonating Orca's desktop client. Also the thing that
|
|
# clears Cloudflare's User-Agent gate in front of the API.
|
|
_USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
|
|
|
|
# Refresh the access token when it has less than this much life left, so a
|
|
# slow downstream API call doesn't expire the token mid-flight.
|
|
_REFRESH_LEEWAY = timedelta(minutes=5)
|
|
|
|
# How long a device-code pairing attempt stays valid before the user must
|
|
# restart. The server also enforces this (``expires_in`` on the device-code
|
|
# response is 600s); we mirror it client-side so we stop polling a dead code.
|
|
DEVICE_CODE_TTL = timedelta(minutes=10)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Device-poll outcomes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DevicePoll:
|
|
"""String outcomes of one :meth:`OrcaCloudService.poll_token` attempt.
|
|
|
|
``PENDING`` / ``SLOW_DOWN`` are non-terminal (keep polling; on SLOW_DOWN
|
|
widen the interval). ``DENIED`` / ``EXPIRED`` are terminal — the pairing
|
|
attempt is dead and the user must restart. ``COMPLETE`` means tokens were
|
|
issued and applied to the service."""
|
|
|
|
PENDING = "authorization_pending"
|
|
SLOW_DOWN = "slow_down"
|
|
DENIED = "access_denied"
|
|
EXPIRED = "expired_token"
|
|
COMPLETE = "complete"
|
|
|
|
#: Non-terminal — the frontend should poll again.
|
|
ONGOING = frozenset({PENDING, SLOW_DOWN})
|
|
#: Terminal failure — the frontend should restart the flow.
|
|
TERMINAL = frozenset({DENIED, EXPIRED})
|
|
|
|
|
|
class OrcaCloudError(Exception):
|
|
"""Base exception for Orca Cloud errors (network / unexpected server)."""
|
|
|
|
pass
|
|
|
|
|
|
class OrcaCloudAuthError(OrcaCloudError):
|
|
"""Authentication / token-related errors. The caller should typically
|
|
prompt the user to reconnect — neither a fresh access token nor a refresh
|
|
will recover without re-pairing."""
|
|
|
|
pass
|
|
|
|
|
|
_shared_http_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
|
"""Register an app-scoped ``httpx.AsyncClient`` so per-request
|
|
``OrcaCloudService`` instances can reuse its connection pool. Mirrors the
|
|
pattern used by :mod:`backend.app.services.bambu_cloud`."""
|
|
global _shared_http_client
|
|
_shared_http_client = client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Service class
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class OrcaCloudService:
|
|
"""Stateful per-request client for the Orca Cloud external API.
|
|
|
|
Instantiated by the route layer, populated with a stored token via
|
|
:meth:`set_tokens`, then used to call the sync endpoints. Token rotation
|
|
on refresh is the route layer's responsibility (see :meth:`refresh` —
|
|
mutates ``self`` and returns the new pair, but does NOT persist).
|
|
"""
|
|
|
|
def __init__(self, client: httpx.AsyncClient | None = None):
|
|
self.access_token: str | None = None
|
|
self.refresh_token: str | None = None
|
|
self.token_expiry: datetime | None = None
|
|
# Mirror the bambu_cloud pattern for client ownership: prefer injected
|
|
# client (tests), fall back to app-scoped shared client (production),
|
|
# else create our own so ad-hoc scripts still work.
|
|
if client is not None:
|
|
self._client = client
|
|
self._owns_client = False
|
|
elif _shared_http_client is not None:
|
|
self._client = _shared_http_client
|
|
self._owns_client = False
|
|
else:
|
|
self._client = httpx.AsyncClient(timeout=30.0)
|
|
self._owns_client = True
|
|
|
|
@property
|
|
def is_authenticated(self) -> bool:
|
|
"""True iff we have an access token that won't expire within
|
|
:data:`_REFRESH_LEEWAY`. The leeway prevents a slow API call from
|
|
timing out mid-flight on a token that was nominally still valid."""
|
|
if not self.access_token:
|
|
return False
|
|
if self.token_expiry is None:
|
|
# No expiry recorded — pessimistically treat as expired so the
|
|
# caller refreshes before use.
|
|
return False
|
|
return datetime.now(timezone.utc) + _REFRESH_LEEWAY < self.token_expiry
|
|
|
|
def set_tokens(
|
|
self,
|
|
access_token: str | None,
|
|
refresh_token: str | None,
|
|
expires_at: datetime | None,
|
|
) -> None:
|
|
"""Hydrate the service from stored credentials."""
|
|
self.access_token = access_token
|
|
self.refresh_token = refresh_token
|
|
# Normalize to timezone-aware UTC so subsequent comparisons against
|
|
# ``datetime.now(timezone.utc)`` are well-defined. asyncpg returns
|
|
# naive datetimes from a ``TIMESTAMP WITHOUT TIME ZONE`` column —
|
|
# we treat naive values as UTC since that's how we stored them.
|
|
if expires_at is not None and expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
self.token_expiry = expires_at
|
|
|
|
def clear_tokens(self) -> None:
|
|
"""Forget all credentials. Used on logout and after auth failures."""
|
|
self.access_token = None
|
|
self.refresh_token = None
|
|
self.token_expiry = None
|
|
|
|
def _api_headers(self) -> dict[str, str]:
|
|
"""Headers for calls to the external API. Requires a bearer token —
|
|
callers should ensure the service is authenticated first."""
|
|
if not self.access_token:
|
|
raise OrcaCloudAuthError("Orca Cloud API requires an access token")
|
|
return {
|
|
"User-Agent": _USER_AGENT,
|
|
"Authorization": f"Bearer {self.access_token}",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Device authorization grant (RFC 8628)
|
|
# ------------------------------------------------------------------
|
|
|
|
async def request_device_code(
|
|
self,
|
|
scope: str = ORCA_SCOPE,
|
|
instance_url: str | None = None,
|
|
instance_label: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Start a pairing attempt. Returns the raw device-code response
|
|
(``device_code``, ``user_code``, ``verification_uri``,
|
|
``verification_uri_complete``, ``expires_in``, ``interval``).
|
|
|
|
``instance_url`` / ``instance_label`` are display-only fields shown on
|
|
the user's approval card (anti-phishing context). The ``device_code``
|
|
is a secret the caller must keep server-side; only ``user_code`` and
|
|
the verification URIs are safe to show the user."""
|
|
url = f"{ORCA_API_BASE}/oauth/device/code"
|
|
form: dict[str, str] = {"client_id": ORCA_CLIENT_ID, "scope": scope}
|
|
if instance_url:
|
|
form["instance_url"] = instance_url
|
|
if instance_label:
|
|
form["instance_label"] = instance_label
|
|
try:
|
|
resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
|
|
except httpx.HTTPError as e:
|
|
raise OrcaCloudError(f"Network error requesting Orca Cloud device code: {e}") from e
|
|
|
|
if resp.status_code >= 400:
|
|
detail = _describe_token_error(resp)
|
|
# invalid_client means our client_id is wrong / unregistered — an
|
|
# operator misconfiguration, not something the user can fix.
|
|
if resp.status_code in (400, 401, 403):
|
|
raise OrcaCloudAuthError(f"Orca Cloud rejected the device-code request: {detail}")
|
|
raise OrcaCloudError(f"Orca Cloud device-code request failed ({resp.status_code}): {detail}")
|
|
return resp.json()
|
|
|
|
async def poll_token(self, device_code: str) -> tuple[str, dict[str, Any] | None]:
|
|
"""Poll the token endpoint once for a pending device-code grant.
|
|
|
|
Returns ``(status, data)`` where ``status`` is a :class:`DevicePoll`
|
|
value. On :data:`DevicePoll.COMPLETE` the service is mutated with the
|
|
new tokens and ``data`` is the raw token response (so the caller can
|
|
persist it); otherwise ``data`` is ``None``.
|
|
|
|
Raises :class:`OrcaCloudError` only for genuinely unexpected responses
|
|
(5xx, network, or an unrecognized error code) — the four RFC error
|
|
codes are returned as statuses, not raised, because they're normal
|
|
control flow for a polling loop."""
|
|
url = f"{ORCA_API_BASE}/oauth/token"
|
|
form = {
|
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
"device_code": device_code,
|
|
"client_id": ORCA_CLIENT_ID,
|
|
}
|
|
try:
|
|
resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
|
|
except httpx.HTTPError as e:
|
|
raise OrcaCloudError(f"Network error polling Orca Cloud token endpoint: {e}") from e
|
|
|
|
if resp.status_code < 400:
|
|
data = resp.json()
|
|
self._apply_token_response(data)
|
|
return DevicePoll.COMPLETE, data
|
|
|
|
# RFC 8628 error bodies: {"error": "authorization_pending" | ...}.
|
|
error = _error_code(resp)
|
|
if error == "authorization_pending":
|
|
return DevicePoll.PENDING, None
|
|
if error == "slow_down":
|
|
return DevicePoll.SLOW_DOWN, None
|
|
if error == "access_denied":
|
|
return DevicePoll.DENIED, None
|
|
# expired_token and invalid_grant both mean "this device code is dead,
|
|
# start over" — collapse them to a single terminal EXPIRED status.
|
|
if error in ("expired_token", "invalid_grant"):
|
|
return DevicePoll.EXPIRED, None
|
|
raise OrcaCloudError(f"Orca Cloud token poll failed ({resp.status_code}): {_describe_token_error(resp)}")
|
|
|
|
async def refresh(self) -> dict[str, Any]:
|
|
"""Use the stored refresh token to obtain a fresh access/refresh pair.
|
|
|
|
Refresh tokens are single-use — the old one is consumed the moment
|
|
this succeeds. The caller MUST persist the new pair atomically; a
|
|
crash between this return and the DB write strands the user (though
|
|
Orca's ~60s grace window means a *replay* of the old token within that
|
|
window still yields a working pair rather than revoking). Returns the
|
|
raw token-response dict so the caller has the full new pair."""
|
|
if not self.refresh_token:
|
|
raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
|
|
|
|
url = f"{ORCA_API_BASE}/oauth/token"
|
|
form = {
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": self.refresh_token,
|
|
"client_id": ORCA_CLIENT_ID,
|
|
}
|
|
try:
|
|
resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
|
|
except httpx.HTTPError as e:
|
|
raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
|
|
|
|
if resp.status_code >= 400:
|
|
detail = _describe_token_error(resp)
|
|
# 400 invalid_grant on refresh = expired / already-used / the user
|
|
# disconnected us. Unrecoverable — clear and force a re-pair.
|
|
if resp.status_code in (400, 401, 403):
|
|
self.clear_tokens()
|
|
raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
|
|
raise OrcaCloudError(f"Orca Cloud refresh failed ({resp.status_code}): {detail}")
|
|
|
|
data = resp.json()
|
|
self._apply_token_response(data)
|
|
return data
|
|
|
|
def _apply_token_response(self, data: dict[str, Any]) -> None:
|
|
"""Update ``self.access_token`` / ``self.refresh_token`` /
|
|
``self.token_expiry`` from a token-response payload. Caller is still
|
|
responsible for persisting the values to the DB."""
|
|
access = data.get("access_token")
|
|
refresh = data.get("refresh_token")
|
|
expires_in = data.get("expires_in")
|
|
if not access:
|
|
raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
|
|
self.access_token = access
|
|
# The token endpoint always rotates the refresh token; if a response
|
|
# omits one we keep the previous value to avoid stranding the session,
|
|
# but that shouldn't happen in practice.
|
|
if refresh:
|
|
self.refresh_token = refresh
|
|
if isinstance(expires_in, (int, float)) and expires_in > 0:
|
|
self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
|
|
else:
|
|
self.token_expiry = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# External API
|
|
# ------------------------------------------------------------------
|
|
|
|
async def introspect(self) -> dict[str, Any]:
|
|
"""Return the pairing's introspection record (``user_id``,
|
|
``client_id``, ``connection_id``, ``scope``, ``expires_at``). Used
|
|
after pairing to record the user's id for display in Bambuddy's UI."""
|
|
url = f"{ORCA_API_BASE}/api/v1/external-apps/me"
|
|
try:
|
|
resp = await self._client.get(url, headers=self._api_headers())
|
|
except httpx.HTTPError as e:
|
|
raise OrcaCloudError(f"Network error fetching Orca Cloud introspection: {e}") from e
|
|
if resp.status_code == 401:
|
|
raise OrcaCloudAuthError("Orca Cloud introspection unauthorized — token expired or revoked")
|
|
if resp.status_code >= 400:
|
|
raise OrcaCloudError(f"Orca Cloud introspection failed ({resp.status_code}): {resp.text[:200]}")
|
|
return resp.json()
|
|
|
|
async def list_profiles(self) -> list[dict[str, Any]]:
|
|
"""Return the user's Orca Cloud profiles as a flat list of profile
|
|
entries (``{id, name, content, updated_time, created_time}``) —
|
|
forwarded verbatim; callers pick the fields they need.
|
|
|
|
Uses ``GET /api/v1/external/sync/pull`` with NO ``?cursor=`` parameter,
|
|
the documented "full snapshot" bootstrap. Sending ``cursor=0`` instead
|
|
trips ``410 cursor_too_old`` (the sync log doesn't reach back to the
|
|
Unix epoch). The pull response is ``{next_cursor, upserts, deletes}``;
|
|
we return ``upserts`` and ignore the rest (no prior client state to
|
|
invalidate on a read-only list)."""
|
|
url = f"{ORCA_API_BASE}/api/v1/external/sync/pull"
|
|
try:
|
|
resp = await self._client.get(url, headers=self._api_headers())
|
|
except httpx.HTTPError as e:
|
|
raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
|
|
if resp.status_code == 401:
|
|
raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
|
|
if resp.status_code == 410:
|
|
# cursor_too_old on a no-cursor request would be surprising, but
|
|
# surface it clearly rather than as an opaque 502.
|
|
raise OrcaCloudError("Orca Cloud sync cursor too old — a full resync is required")
|
|
if resp.status_code >= 400:
|
|
raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
|
|
data = resp.json()
|
|
if isinstance(data, dict):
|
|
upserts = data.get("upserts")
|
|
if isinstance(upserts, list):
|
|
return upserts
|
|
# Tolerate a flat-list shape if Orca ever rolls one out here.
|
|
for key in ("profiles", "data"):
|
|
value = data.get(key)
|
|
if isinstance(value, list):
|
|
return value
|
|
if isinstance(data, list):
|
|
return data
|
|
logger.warning("Orca Cloud /external/sync/pull returned unexpected shape: %r", type(data).__name__)
|
|
return []
|
|
|
|
async def get_profile(self, profile_id: str) -> dict[str, Any]:
|
|
"""Fetch a single profile's full content. The external sync API has no
|
|
per-profile GET, so we list and filter. For the realistic profile
|
|
counts this is fine; if it becomes a hot path we'll add caching at the
|
|
route layer rather than hammer the pull endpoint."""
|
|
profiles = await self.list_profiles()
|
|
for profile in profiles:
|
|
if str(profile.get("id")) == str(profile_id):
|
|
return profile
|
|
raise OrcaCloudError(f"Orca Cloud profile {profile_id!r} not found (scanned {len(profiles)} profiles)")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
async def close(self) -> None:
|
|
"""Release the underlying httpx client iff we own it. No-op if we're
|
|
using an injected or app-shared client (those are managed elsewhere)."""
|
|
if self._owns_client:
|
|
await self._client.aclose()
|
|
|
|
|
|
def _error_code(resp: httpx.Response) -> str | None:
|
|
"""Extract the RFC-style ``error`` code from a token-endpoint error body,
|
|
or ``None`` if the body doesn't parse as ``{"error": "..."}``."""
|
|
try:
|
|
data = resp.json()
|
|
except (json.JSONDecodeError, ValueError):
|
|
return None
|
|
if isinstance(data, dict):
|
|
err = data.get("error")
|
|
if isinstance(err, str) and err:
|
|
return err
|
|
return None
|
|
|
|
|
|
def _describe_token_error(resp: httpx.Response) -> str:
|
|
"""Best-effort extraction of a user-facing message from a token-endpoint
|
|
error response. Tries JSON fields in order; falls back to the raw body
|
|
(truncated) if nothing parses."""
|
|
try:
|
|
data = resp.json()
|
|
except (json.JSONDecodeError, ValueError):
|
|
return (resp.text or "<empty body>")[:200]
|
|
if not isinstance(data, dict):
|
|
return str(data)[:200]
|
|
for key in ("error_description", "msg", "error", "message"):
|
|
val = data.get(key)
|
|
if isinstance(val, str) and val:
|
|
return val
|
|
return str(data)[:200]
|