Files
maziggy f3b1c59169 fix(orca-cloud): close the HTTP client when an authenticated build fails
OrcaCloudService owns an httpx client from construction, and every path in
_build_authenticated_service after that point can raise: no stored refresh
token, a rejected refresh, an unreachable Orca, and the token-rotation write.
On success the caller closes the client. On failure nobody is ever handed it,
so all four paths leaked one into the connection pool.

That went unnoticed while the only callers were routes, where the trigger is a
person retrying a broken sign-in a handful of times. It stopped being harmless
in 9434875f, which added a caller in spool assignment -- one build per
Orca-referenced spool, failing on every assignment for as long as the stored
credentials cannot be refreshed.

The unwind guard catches BaseException rather than Exception: a cancelled
request leaks the client just as surely as a failed refresh, and cancellation
during shutdown is exactly when dangling sockets are least welcome. The close
inside it is guarded in turn, so a failing cleanup cannot replace the error the
caller needs to see -- least of all a CancelledError, which has to keep
propagating for cancellation to work at all.

Six tests. Four fail against the unguarded builder, verified by reverting the
guard and re-running; the other two pin the surrounding contract (a failing
close must not mask the real error, and a successful build must leave the
client open for its caller) and pass either way. The shared _expired_service
helper now gives the mock an awaitable close(), so the four pre-existing
refresh tests exercise the same path.

Also corrects two comments and the changelog entry from 9434875f, which
overstated what the captures support. They claimed Bambu Cloud returns a
preset's filament_id in either of two places and only one was read. The
responses recorded in #1053 show something narrower: a Studio-created preset
carries it on the envelope, and an Orca-created one has none at all -- the
envelope says null and `setting` is a delta from the base. The `setting` lookup
stays as belt-and-braces for a shape no captured response has needed yet, but
it is not why a custom profile reached the slicer as its base. That is the
OrcaSlicer preset format having no filament_id field, filed upstream as
OrcaSlicer PR #13315.

The eight-character truncation is now evidenced across three models rather than
one -- an A1 storing PFUS9DDC of PFUS9DDC938FE3AB8F, a P1S storing PFUS7A65 of
PFUS7A65290D3DADC4, and an H2D storing 8219C45D of an Orca profile UUID.
2026-09-07 10:39:10 +02:00

710 lines
29 KiB
Python

"""
Orca Cloud API Routes
Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
Orca Cloud external-app surface.
Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
POST /orca-cloud/device/start
Request a device code, persist it server-side (TTL 10 min), return the
user_code + verification URIs + poll interval.
POST /orca-cloud/device/poll
One poll of the token endpoint. Returns an in-progress status while the
user approves; on approval, persists the token pair and reports
connected. The frontend calls this every ``interval`` seconds.
GET /orca-cloud/status
Connected/disconnected + user_id.
POST /orca-cloud/logout
Clear stored tokens (Bambuddy then has no token to use; the user can
also disconnect from Orca Cloud's own settings to revoke server-side).
GET /orca-cloud/profiles
List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
the access token if it's within the refresh 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 pending device-code state (device_code, interval,
started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
pattern; no schema change from the previous PKCE flow.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
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 (
OrcaAuthStatusResponse,
OrcaDevicePollResponse,
OrcaDeviceStartResponse,
OrcaProfileDetail,
OrcaProfileListResponse,
OrcaProfileMeta,
)
from backend.app.services.orca_cloud import (
DEVICE_CODE_TTL,
DevicePoll,
OrcaCloudAuthError,
OrcaCloudError,
OrcaCloudService,
)
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 profile (``{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. The ``pending_*`` keys hold the
# transient device-code state (device_code / interval / started_at).
_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_device_code": "orca_cloud_pending_verifier", # reused column
"pending_interval": "orca_cloud_pending_state", # reused column
"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.
``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
columns that the old PKCE flow used for its verifier/state)."""
__slots__ = (
"token",
"refresh_token",
"expires_at",
"email",
"user_id",
"pending_device_code",
"pending_interval",
"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_device_code: str | None = None
self.pending_interval: 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_device_code = user.orca_cloud_pending_verifier
creds.pending_interval = 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_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
return creds
async def _persist_pending_device(
db: AsyncSession,
user: User | None,
device_code: str,
interval: int,
when: datetime,
) -> None:
"""Store the transient device-code state used by ``/device/start`` ->
``/device/poll``. The device_code is a secret kept server-side."""
if user is not None:
await db.execute(
update(User)
.where(User.id == user.id)
.values(
orca_cloud_pending_verifier=device_code,
orca_cloud_pending_state=str(interval),
orca_cloud_pending_at=when,
)
)
await db.commit()
return
await _upsert_settings(
db,
{
_SETTINGS_KEYS["pending_device_code"]: device_code,
_SETTINGS_KEYS["pending_interval"]: str(interval),
_SETTINGS_KEYS["pending_at"]: _iso(when),
},
)
async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
"""Wipe just the pending device-code state (on terminal poll outcomes),
leaving any existing tokens untouched."""
if user is not None:
await db.execute(
update(User)
.where(User.id == user.id)
.values(
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["pending_device_code"]: None,
_SETTINGS_KEYS["pending_interval"]: None,
_SETTINGS_KEYS["pending_at"]: None,
},
)
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 device-code state on the same
write, since by this point the pairing 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_device_code"]: None,
_SETTINGS_KEYS["pending_interval"]: 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 state (refresh happens long after pairing)."""
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,
clear_on_auth_failure: bool = True,
) -> 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.
We don't lock around the refresh: Orca tolerates concurrent refreshes for
~60s (each racer gets its own valid pair on the same connection rather than
a revoke), so a lost race here is harmless — last-write-wins on the stored
pair, and whichever pair we keep is valid.
``clear_on_auth_failure`` controls what happens when the refresh is
rejected. Routes leave it on: the caller is a person looking at the UI, and
wiping the dead credentials flips the page to disconnected in front of them
so they can pair again. Background jobs pass ``False`` — see the caveat
below.
Why background callers must not clear: Orca reports every rejection with
one composite reason (``unknown, expired, revoked, or already used``), so
a genuine revocation is indistinguishable from a lost refresh-rotation
race. Acting destructively on a signal that can't be disambiguated is the
#2562 mistake in a different cloud. It also gains nothing — a route call
hits the same failure and clears then, at a moment the user can respond to.
A successful refresh is still persisted either way: by that point the old
refresh token is consumed, so dropping the new pair would break a working
pairing for real.
"""
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()
# The service owns an httpx client from construction, and every path below
# this point can raise. On success the caller closes it; on failure nobody
# ever holds it, so it has to be closed here or the connection pool leaks
# one client per failed build. That went unnoticed while the only callers
# were routes -- a person retrying a broken sign-in a few times -- and
# became worth fixing once spool assignment started building one too.
try:
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 — unless
# the caller is a background job, which must not change sign-in
# state on its own.
if clear_on_auth_failure:
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)
except BaseException:
# BaseException, not Exception: a cancelled request leaks the client
# just as surely as a failed refresh does. The close is guarded in turn
# because failing to clean up must not replace the error the caller
# needs to see -- least of all a CancelledError, which has to keep
# propagating for cancellation to work at all.
try:
await svc.close()
except Exception as close_err: # noqa: BLE001 - cleanup is best-effort
logger.debug("Orca Cloud client close failed while unwinding a failed build: %s", close_err)
raise
return svc
# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
@router.post("/device/start", response_model=OrcaDeviceStartResponse)
async def device_start(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
):
"""Begin device pairing. Requests a device code from Orca, stores it
server-side (the device_code is a secret and never leaves the backend),
and returns the user_code + verification URIs + poll interval for the
frontend to display and poll against."""
svc = OrcaCloudService()
# instance_url/label are display-only anti-phishing context on the approval
# card. base_url may be off behind a reverse proxy, but it's harmless if so.
instance_url = str(request.base_url).rstrip("/") or None
try:
data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
except OrcaCloudAuthError as e:
# invalid_client etc. — an operator misconfiguration, not user error.
raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
except OrcaCloudError as e:
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
device_code = data.get("device_code")
user_code = data.get("user_code")
if not device_code or not user_code:
raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
interval = int(data.get("interval") or 5)
expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
return OrcaDeviceStartResponse(
user_code=user_code,
verification_uri=str(data.get("verification_uri") or ""),
verification_uri_complete=str(data.get("verification_uri_complete") or ""),
interval=interval,
expires_in=expires_in,
)
@router.post("/device/poll", response_model=OrcaDevicePollResponse)
async def device_poll(
db: AsyncSession = Depends(get_db),
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
):
"""Poll the token endpoint once for the in-flight pairing. Returns an
in-progress status while the user approves; on approval persists the token
pair (clearing the pending state) and reports connected."""
creds = await _load_credentials(db, current_user)
if not creds.pending_device_code or not creds.pending_at:
raise HTTPException(
status_code=400,
detail="No pending Orca Cloud pairing. 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 delta.
age = datetime.now(timezone.utc) - creds.pending_at
if age > DEVICE_CODE_TTL:
await _clear_pending_device(db, current_user)
return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
svc = OrcaCloudService()
try:
status, token_data = await svc.poll_token(creds.pending_device_code)
except OrcaCloudError as e:
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
if status in DevicePoll.ONGOING:
return OrcaDevicePollResponse(status=status, connected=False)
if status in DevicePoll.TERMINAL:
# access_denied / expired_token — the attempt is dead; clear it so the
# user starts fresh next time.
await _clear_pending_device(db, current_user)
return OrcaDevicePollResponse(status=status, connected=False)
# COMPLETE — tokens issued and applied to svc. Introspect for the user_id
# (the external API's /me doesn't return an email, so email stays None).
user_id: str | None = None
try:
info = await svc.introspect()
if isinstance(info, dict):
user_id = _str_or_none(info.get("user_id"))
except OrcaCloudError as e:
# Don't fail the whole pairing over the side introspection call — we
# have valid tokens, which is the load-bearing part.
logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, 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 Orca's disconnect
endpoint (the user can revoke server-side from Orca Cloud's own settings;
Bambuddy will no longer have the token to use either way)."""
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,
)