mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.
The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.
It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.
Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.
Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:
cloud_profiles/bambu/user-3/{filament,printer,process}.json
cloud_profiles/orca/user-3/{filament,printer,process}.json
Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.
Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.
Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.
The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.
The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.
One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.
Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
692 lines
28 KiB
Python
692 lines
28 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()
|
|
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)
|
|
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,
|
|
)
|