orca_cloud.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. """
  2. Orca Cloud API Routes
  3. PKCE-based connect/disconnect + profile sync endpoints for the
  4. Orca Cloud (Supabase) profile-sync surface.
  5. Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
  6. POST /orca-cloud/auth/start
  7. Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
  8. POST /orca-cloud/auth/finish
  9. Parse the pasted callback URL, validate state for CSRF, exchange the
  10. code for tokens, persist them atomically.
  11. GET /orca-cloud/status
  12. Connected/disconnected + email + user_id.
  13. POST /orca-cloud/logout
  14. Clear stored tokens (no Supabase-side revocation — token still
  15. survives until its 1h expiry, but Bambuddy has no way to use it).
  16. GET /orca-cloud/profiles
  17. Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
  18. access token if it's within the 5-min leeway of expiry.
  19. GET /orca-cloud/profiles/{id}
  20. Single profile's full content.
  21. Storage shape mirrors the Bambu Cloud surface: per-user columns on
  22. ``users`` when auth is enabled, fallback to global ``settings`` keys when
  23. auth is disabled. The transient PKCE state (verifier, state, pending_at)
  24. is stored alongside the tokens — same dual-mode pattern.
  25. """
  26. from __future__ import annotations
  27. import logging
  28. from datetime import datetime, timezone
  29. from fastapi import APIRouter, Depends, HTTPException
  30. from sqlalchemy import select, update
  31. from sqlalchemy.ext.asyncio import AsyncSession
  32. from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller
  33. from backend.app.core.database import get_db
  34. from backend.app.core.permissions import Permission
  35. from backend.app.models.settings import Settings
  36. from backend.app.models.user import User
  37. from backend.app.schemas.orca_cloud import (
  38. OrcaAuthFinishRequest,
  39. OrcaAuthPasswordRequest,
  40. OrcaAuthStartRequest,
  41. OrcaAuthStartResponse,
  42. OrcaAuthStatusResponse,
  43. OrcaProfileDetail,
  44. OrcaProfileListResponse,
  45. OrcaProfileMeta,
  46. )
  47. from backend.app.services.orca_cloud import (
  48. PENDING_PKCE_TTL,
  49. OrcaCloudAuthError,
  50. OrcaCloudError,
  51. OrcaCloudService,
  52. build_authorize_url,
  53. generate_pkce,
  54. parse_callback_url,
  55. )
  56. logger = logging.getLogger(__name__)
  57. # Router-level dependency: enforce the same API-key cloud-access fence as the
  58. # Bambu Cloud router (rejects ownerless legacy keys, requires the
  59. # ``can_access_cloud`` scope, stashes the owner on ``request.state`` so
  60. # per-route deps can resolve it as the effective ``current_user``).
  61. # Without this gate the kiosk's API-keyed requests sail past with
  62. # ``current_user=None`` → ``_build_authenticated_service`` falls back to
  63. # the global Settings table → no Orca token → 401, no presets surfaced.
  64. # Bambu Cloud works in the same kiosk because its router has this gate.
  65. router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)])
  66. # Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary.
  67. # Empirically (confirmed against a live account on 2026-06-04): Orca uses
  68. # ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio
  69. # ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere
  70. # in the OrcaSlicer source. The aliases keep us forward-compatible if Orca
  71. # ever flips back to the older naming.
  72. _ORCA_TYPE_TO_BAMBU = {
  73. "filament": "filament",
  74. "printer": "printer",
  75. "machine": "printer", # alias for the BambuStudio-style naming
  76. "print": "process",
  77. "process": "process", # alias for the BambuStudio-style naming
  78. }
  79. def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
  80. """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
  81. into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
  82. isn't a dict or the type isn't one we render."""
  83. content = orca_profile.get("content") or {}
  84. if not isinstance(content, dict):
  85. return None
  86. bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", "")))
  87. if bambu_type is None:
  88. return None
  89. pid = orca_profile.get("id")
  90. if pid is None:
  91. return None
  92. updated = orca_profile.get("updated_time")
  93. return OrcaProfileMeta(
  94. setting_id=str(pid),
  95. name=str(orca_profile.get("name") or pid),
  96. type=bambu_type,
  97. version=_str_or_none(content.get("version")),
  98. # ``from`` distinguishes ``system`` (bundled) from ``User`` (custom),
  99. # same field the Bambu source-of-truth uses for that distinction.
  100. user_id=_str_or_none(content.get("user_id") or content.get("from")),
  101. updated_time=str(updated) if updated is not None else None,
  102. # Every profile that lives in the user's Orca Cloud account is by
  103. # definition user-authored; bundled defaults aren't synced.
  104. is_custom=True,
  105. )
  106. def _str_or_none(value: object) -> str | None:
  107. """Cast non-empty scalars to ``str``; pass ``None`` and empty values
  108. through unchanged. Used to keep the response shape consistent when
  109. Orca's source data has heterogenous typing for the same field."""
  110. if value is None:
  111. return None
  112. s = str(value)
  113. return s if s else None
  114. # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
  115. # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
  116. # settings table see a consistent prefix.
  117. _SETTINGS_KEYS = {
  118. "token": "orca_cloud_token",
  119. "refresh_token": "orca_cloud_refresh_token",
  120. "expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string
  121. "email": "orca_cloud_email",
  122. "user_id": "orca_cloud_user_id",
  123. "pending_verifier": "orca_cloud_pending_verifier",
  124. "pending_state": "orca_cloud_pending_state",
  125. "pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string
  126. }
  127. # ---------------------------------------------------------------------------
  128. # Storage helpers — bridge User-row vs Settings-table fallback transparently
  129. # ---------------------------------------------------------------------------
  130. def _iso(dt: datetime | None) -> str | None:
  131. """Serialize a datetime to ISO 8601 UTC. ``None`` passes through."""
  132. if dt is None:
  133. return None
  134. if dt.tzinfo is None:
  135. dt = dt.replace(tzinfo=timezone.utc)
  136. return dt.astimezone(timezone.utc).isoformat()
  137. def _as_utc(dt: datetime | None) -> datetime | None:
  138. """Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as
  139. UTC. ``None`` passes through. Already-aware datetimes are converted to
  140. UTC to normalize."""
  141. if dt is None:
  142. return None
  143. if dt.tzinfo is None:
  144. return dt.replace(tzinfo=timezone.utc)
  145. return dt.astimezone(timezone.utc)
  146. def _parse_iso(value: str | None) -> datetime | None:
  147. """Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through."""
  148. if not value:
  149. return None
  150. try:
  151. dt = datetime.fromisoformat(value)
  152. except (TypeError, ValueError):
  153. return None
  154. if dt.tzinfo is None:
  155. dt = dt.replace(tzinfo=timezone.utc)
  156. return dt
  157. class _OrcaCredentials:
  158. """Lightweight bag for stored Orca Cloud credentials. We use a class
  159. rather than a dataclass so the helpers can mutate it as needed during
  160. JIT-refresh without rebuilding the whole object."""
  161. __slots__ = (
  162. "token",
  163. "refresh_token",
  164. "expires_at",
  165. "email",
  166. "user_id",
  167. "pending_verifier",
  168. "pending_state",
  169. "pending_at",
  170. )
  171. def __init__(self) -> None:
  172. self.token: str | None = None
  173. self.refresh_token: str | None = None
  174. self.expires_at: datetime | None = None
  175. self.email: str | None = None
  176. self.user_id: str | None = None
  177. self.pending_verifier: str | None = None
  178. self.pending_state: str | None = None
  179. self.pending_at: datetime | None = None
  180. async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials:
  181. """Load stored Orca Cloud credentials for the caller (user-row when auth
  182. is enabled, Settings fallback when auth is disabled).
  183. Datetimes coming back from the User row are NAIVE on the Postgres side
  184. (asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but
  185. represent UTC moments because that's what we stored. We attach
  186. ``tzinfo=UTC`` here so downstream comparisons against
  187. ``datetime.now(timezone.utc)`` don't get shifted by the host's local
  188. offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on
  189. a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and
  190. fires the 10-minute TTL guard immediately."""
  191. creds = _OrcaCredentials()
  192. if user is not None:
  193. creds.token = user.orca_cloud_token
  194. creds.refresh_token = user.orca_cloud_refresh_token
  195. creds.expires_at = _as_utc(user.orca_cloud_expires_at)
  196. creds.email = user.orca_cloud_email
  197. creds.user_id = user.orca_cloud_user_id
  198. creds.pending_verifier = user.orca_cloud_pending_verifier
  199. creds.pending_state = user.orca_cloud_pending_state
  200. creds.pending_at = _as_utc(user.orca_cloud_pending_at)
  201. return creds
  202. result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
  203. raw = {s.key: s.value for s in result.scalars().all()}
  204. creds.token = raw.get(_SETTINGS_KEYS["token"])
  205. creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"])
  206. creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
  207. creds.email = raw.get(_SETTINGS_KEYS["email"])
  208. creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
  209. creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
  210. creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
  211. creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
  212. return creds
  213. async def _persist_pending_pkce(
  214. db: AsyncSession,
  215. user: User | None,
  216. verifier: str,
  217. state: str,
  218. when: datetime,
  219. ) -> None:
  220. """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
  221. if user is not None:
  222. await db.execute(
  223. update(User)
  224. .where(User.id == user.id)
  225. .values(
  226. orca_cloud_pending_verifier=verifier,
  227. orca_cloud_pending_state=state,
  228. orca_cloud_pending_at=when,
  229. )
  230. )
  231. await db.commit()
  232. return
  233. await _upsert_settings(
  234. db,
  235. {
  236. _SETTINGS_KEYS["pending_verifier"]: verifier,
  237. _SETTINGS_KEYS["pending_state"]: state,
  238. _SETTINGS_KEYS["pending_at"]: _iso(when),
  239. },
  240. )
  241. async def _persist_tokens(
  242. db: AsyncSession,
  243. user: User | None,
  244. access_token: str,
  245. refresh_token: str | None,
  246. expires_at: datetime | None,
  247. email: str | None,
  248. user_id: str | None,
  249. ) -> None:
  250. """Atomically write the new access/refresh pair to whichever backing store
  251. the deployment uses. Also clears the pending PKCE state on the same write,
  252. since by this point the handshake is complete."""
  253. if user is not None:
  254. await db.execute(
  255. update(User)
  256. .where(User.id == user.id)
  257. .values(
  258. orca_cloud_token=access_token,
  259. orca_cloud_refresh_token=refresh_token,
  260. orca_cloud_expires_at=expires_at,
  261. orca_cloud_email=email,
  262. orca_cloud_user_id=user_id,
  263. orca_cloud_pending_verifier=None,
  264. orca_cloud_pending_state=None,
  265. orca_cloud_pending_at=None,
  266. )
  267. )
  268. await db.commit()
  269. return
  270. await _upsert_settings(
  271. db,
  272. {
  273. _SETTINGS_KEYS["token"]: access_token,
  274. _SETTINGS_KEYS["refresh_token"]: refresh_token,
  275. _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
  276. _SETTINGS_KEYS["email"]: email,
  277. _SETTINGS_KEYS["user_id"]: user_id,
  278. _SETTINGS_KEYS["pending_verifier"]: None,
  279. _SETTINGS_KEYS["pending_state"]: None,
  280. _SETTINGS_KEYS["pending_at"]: None,
  281. },
  282. )
  283. async def _persist_rotated_tokens(
  284. db: AsyncSession,
  285. user: User | None,
  286. access_token: str,
  287. refresh_token: str | None,
  288. expires_at: datetime | None,
  289. ) -> None:
  290. """Persist tokens after a refresh — does NOT touch email/user_id and does
  291. NOT touch the pending PKCE state (refresh happens long after the handshake)."""
  292. if user is not None:
  293. await db.execute(
  294. update(User)
  295. .where(User.id == user.id)
  296. .values(
  297. orca_cloud_token=access_token,
  298. orca_cloud_refresh_token=refresh_token,
  299. orca_cloud_expires_at=expires_at,
  300. )
  301. )
  302. await db.commit()
  303. return
  304. await _upsert_settings(
  305. db,
  306. {
  307. _SETTINGS_KEYS["token"]: access_token,
  308. _SETTINGS_KEYS["refresh_token"]: refresh_token,
  309. _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
  310. },
  311. )
  312. async def _clear_credentials(db: AsyncSession, user: User | None) -> None:
  313. """Wipe everything Orca-related (tokens, identity, pending state)."""
  314. if user is not None:
  315. await db.execute(
  316. update(User)
  317. .where(User.id == user.id)
  318. .values(
  319. orca_cloud_token=None,
  320. orca_cloud_refresh_token=None,
  321. orca_cloud_expires_at=None,
  322. orca_cloud_email=None,
  323. orca_cloud_user_id=None,
  324. orca_cloud_pending_verifier=None,
  325. orca_cloud_pending_state=None,
  326. orca_cloud_pending_at=None,
  327. )
  328. )
  329. await db.commit()
  330. return
  331. result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
  332. for setting in result.scalars().all():
  333. await db.delete(setting)
  334. await db.commit()
  335. async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None:
  336. """Idempotent upsert into the Settings table. ``None`` values delete the row."""
  337. keys = [k for k, _ in values.items()]
  338. result = await db.execute(select(Settings).where(Settings.key.in_(keys)))
  339. existing = {s.key: s for s in result.scalars().all()}
  340. for key, value in values.items():
  341. row = existing.get(key)
  342. if value is None:
  343. if row is not None:
  344. await db.delete(row)
  345. continue
  346. if row is not None:
  347. row.value = value
  348. else:
  349. db.add(Settings(key=key, value=value))
  350. await db.commit()
  351. # ---------------------------------------------------------------------------
  352. # Authenticated service builder with JIT refresh
  353. # ---------------------------------------------------------------------------
  354. async def _build_authenticated_service(
  355. db: AsyncSession,
  356. user: User | None,
  357. ) -> OrcaCloudService:
  358. """Construct an :class:`OrcaCloudService` pre-populated with stored
  359. credentials. If the access token is within the refresh-leeway of expiry,
  360. proactively refresh and persist the new pair BEFORE returning, so the
  361. next API call doesn't time out mid-flight on an expired token."""
  362. creds = await _load_credentials(db, user)
  363. if not creds.token:
  364. raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
  365. svc = OrcaCloudService()
  366. svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at)
  367. if not svc.is_authenticated:
  368. if not svc.refresh_token:
  369. raise HTTPException(
  370. status_code=401,
  371. detail="Orca Cloud session expired and no refresh token is stored — sign in again.",
  372. )
  373. try:
  374. await svc.refresh()
  375. except OrcaCloudAuthError as e:
  376. # Refresh token was revoked or rotated out from under us. Clear
  377. # the stale credentials so the UI flips to disconnected.
  378. await _clear_credentials(db, user)
  379. raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
  380. except OrcaCloudError as e:
  381. raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
  382. # Persist new pair BEFORE returning. A crash between here and the
  383. # downstream API call would still leave the user with valid stored
  384. # tokens for the next request.
  385. await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry)
  386. return svc
  387. # ---------------------------------------------------------------------------
  388. # Route handlers
  389. # ---------------------------------------------------------------------------
  390. @router.post("/auth/start", response_model=OrcaAuthStartResponse)
  391. async def auth_start(
  392. payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
  393. db: AsyncSession = Depends(get_db),
  394. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  395. ):
  396. """Generate PKCE state and return the Supabase authorize URL for the
  397. requested OAuth provider (google / apple / github). The frontend opens
  398. the URL in a new tab; after sign-in the user pastes the callback URL
  399. back into ``/auth/finish``.
  400. ``state`` is generated but NOT sent to Supabase (it would clash with
  401. GoTrue's internal redirect_to-tracking state). We still persist it so
  402. a future flow change can re-introduce state-based CSRF if needed; CSRF
  403. protection today comes from the PKCE verifier itself, which is
  404. single-use, server-side, and bound to the caller's user row."""
  405. verifier, challenge, state = generate_pkce()
  406. await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
  407. return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
  408. @router.post("/auth/password", response_model=OrcaAuthStatusResponse)
  409. async def auth_password(
  410. payload: OrcaAuthPasswordRequest,
  411. db: AsyncSession = Depends(get_db),
  412. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  413. ):
  414. """Direct email+password sign-in. No browser redirect, no paste flow —
  415. Bambuddy POSTs the credentials to Supabase and stores the returned
  416. tokens. Whether this succeeds depends on Orca's Supabase project
  417. accepting the password grant; if it rejects (the SDK refuses passwords
  418. by design, the backend may follow suit), the caller falls back to an
  419. OAuth provider via ``/auth/start``."""
  420. svc = OrcaCloudService()
  421. try:
  422. await svc.password_login(payload.email, payload.password)
  423. except OrcaCloudAuthError as e:
  424. raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
  425. except OrcaCloudError as e:
  426. raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
  427. email: str | None = None
  428. user_id: str | None = None
  429. try:
  430. user_info = await svc.get_user_info()
  431. if isinstance(user_info, dict):
  432. email = user_info.get("email")
  433. user_id = user_info.get("id")
  434. except OrcaCloudError as e:
  435. logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
  436. await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
  437. return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
  438. @router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
  439. async def auth_finish(
  440. payload: OrcaAuthFinishRequest,
  441. db: AsyncSession = Depends(get_db),
  442. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  443. ):
  444. """Complete the PKCE handshake — parse the pasted callback URL, validate
  445. state (CSRF), exchange the code for tokens, persist."""
  446. creds = await _load_credentials(db, current_user)
  447. if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
  448. raise HTTPException(
  449. status_code=400,
  450. detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
  451. )
  452. # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
  453. # normalization. Subtracting two aware UTC datetimes gives a real wall-clock
  454. # delta with no local-offset shift.
  455. age = datetime.now(timezone.utc) - creds.pending_at
  456. if age > PENDING_PKCE_TTL:
  457. # Don't leave the stale state in the DB — clear it so the user has to
  458. # restart fresh, which forces a new verifier/state pair.
  459. await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
  460. raise HTTPException(
  461. status_code=400,
  462. detail=(
  463. f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
  464. "Click Connect again to start over."
  465. ),
  466. )
  467. code, _callback_state = parse_callback_url(payload.callback_url)
  468. if not code:
  469. raise HTTPException(
  470. status_code=400,
  471. detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
  472. )
  473. # We do NOT validate ``state`` here: Supabase doesn't echo back a state we
  474. # don't send (see :func:`build_authorize_url` for why we can't send one).
  475. # CSRF is protected by PKCE: the verifier is server-side and single-use,
  476. # so an attacker can't complete the exchange with a code they obtained
  477. # separately. ``pending_state`` is still stored for forward compatibility
  478. # if Supabase ever supports a client-passed state alongside redirect_to.
  479. svc = OrcaCloudService()
  480. try:
  481. await svc.exchange_code(code, creds.pending_verifier)
  482. except OrcaCloudAuthError as e:
  483. raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
  484. except OrcaCloudError as e:
  485. raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
  486. # Fetch user info so we can show the connected email in the UI.
  487. email: str | None = None
  488. user_id: str | None = None
  489. try:
  490. user_info = await svc.get_user_info()
  491. if isinstance(user_info, dict):
  492. email = user_info.get("email")
  493. user_id = user_info.get("id")
  494. except OrcaCloudError as e:
  495. # Don't fail the whole connect flow just because the user-info side
  496. # call hiccuped — we have valid tokens, that's the load-bearing part.
  497. logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
  498. await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
  499. return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
  500. @router.get("/status", response_model=OrcaAuthStatusResponse)
  501. async def get_status(
  502. db: AsyncSession = Depends(get_db),
  503. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  504. ):
  505. """Return whether the caller has an Orca Cloud session stored, plus
  506. identifier details for display. Does NOT make a live API call."""
  507. creds = await _load_credentials(db, current_user)
  508. return OrcaAuthStatusResponse(
  509. connected=bool(creds.token),
  510. email=creds.email,
  511. user_id=creds.user_id,
  512. )
  513. @router.post("/logout")
  514. async def logout(
  515. db: AsyncSession = Depends(get_db),
  516. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  517. ):
  518. """Clear stored Orca Cloud credentials. Does not call Supabase's
  519. ``/logout`` endpoint (the token would still survive its 1h expiry there
  520. either way, and Bambuddy will no longer have it to use)."""
  521. await _clear_credentials(db, current_user)
  522. return {"success": True}
  523. @router.get("/profiles", response_model=OrcaProfileListResponse)
  524. async def list_profiles(
  525. db: AsyncSession = Depends(get_db),
  526. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  527. ):
  528. """Return profile metadata grouped by type (``filament`` / ``printer``
  529. / ``process``), matching the ``SlicerSettingsResponse`` shape the
  530. Bambu Cloud tab consumes. This lets the frontend render Orca profiles
  531. with the same visual components — same cards, same filter bar, same
  532. grouping — without separate UI code paths."""
  533. svc = await _build_authenticated_service(db, current_user)
  534. try:
  535. raw_profiles = await svc.list_profiles()
  536. except OrcaCloudAuthError as e:
  537. raise HTTPException(status_code=401, detail=str(e)) from e
  538. except OrcaCloudError as e:
  539. raise HTTPException(status_code=502, detail=str(e)) from e
  540. grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []}
  541. # Log any unknown content.type values we silently drop, so a future
  542. # change in Orca's type vocabulary surfaces in the logs rather than
  543. # quietly losing profiles.
  544. unknown_types: dict[str, int] = {}
  545. for entry in raw_profiles:
  546. setting = _orca_to_setting(entry)
  547. if setting is None:
  548. content = entry.get("content") if isinstance(entry, dict) else None
  549. raw_type = (content.get("type") if isinstance(content, dict) else None) or "<missing>"
  550. unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1
  551. continue
  552. grouped[setting.type].append(setting)
  553. if unknown_types:
  554. logger.warning(
  555. "Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s",
  556. sum(unknown_types.values()),
  557. unknown_types,
  558. )
  559. return OrcaProfileListResponse(**grouped)
  560. @router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail)
  561. async def get_profile(
  562. profile_id: str,
  563. db: AsyncSession = Depends(get_db),
  564. current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
  565. ):
  566. """Fetch a single profile's full content, shaped like
  567. ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it
  568. unchanged. The inner ``setting`` field is the raw slicer-format JSON
  569. Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a
  570. BambuStudio fork."""
  571. svc = await _build_authenticated_service(db, current_user)
  572. try:
  573. profile = await svc.get_profile(profile_id)
  574. except OrcaCloudAuthError as e:
  575. raise HTTPException(status_code=401, detail=str(e)) from e
  576. except OrcaCloudError as e:
  577. if "not found" in str(e).lower():
  578. raise HTTPException(status_code=404, detail=str(e)) from e
  579. raise HTTPException(status_code=502, detail=str(e)) from e
  580. content = profile.get("content") if isinstance(profile, dict) else None
  581. if not isinstance(content, dict):
  582. content = {}
  583. orca_type = str(content.get("type", ""))
  584. bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type)
  585. update_time = profile.get("updated_time") if isinstance(profile, dict) else None
  586. return OrcaProfileDetail(
  587. setting_id=str(profile_id),
  588. name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id),
  589. type=bambu_type,
  590. version=_str_or_none(content.get("version")),
  591. base_id=_str_or_none(content.get("inherits") or content.get("base_id")),
  592. update_time=str(update_time) if update_time is not None else None,
  593. setting=content,
  594. )