diff --git a/CHANGELOG.md b/CHANGELOG.md index de211932d..560ce2623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased +### Fixed +- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests. + ### Added - **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests. - **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it. diff --git a/backend/app/api/routes/github_backup.py b/backend/app/api/routes/github_backup.py index c58d04107..d3165ba03 100644 --- a/backend/app/api/routes/github_backup.py +++ b/backend/app/api/routes/github_backup.py @@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog from backend.app.models.user import User from backend.app.schemas.github_backup import ( + CloudAccountCounts, GitHubBackupConfigCreate, GitHubBackupConfigResponse, GitHubBackupConfigUpdate, @@ -75,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR) +async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]: + """How many Bambu / Orca accounts a backup would collect from. + + Asks the collector itself rather than re-deriving the rule, so the number + the UI gates on can't drift from the number the backup actually uses + (#2717). Counts only — never who. + """ + try: + bambu, orca = await github_backup_service.cloud_accounts(db) + return len(bambu), len(orca) + except Exception: + # A settings page must still render when a credential store is + # unreadable; the toggle simply shows as unavailable. + logger.warning("Failed to count connected cloud accounts", exc_info=True) + return 0, 0 + + +@router.get("/cloud-accounts", response_model=CloudAccountCounts) +async def get_cloud_accounts( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP), +): + """How many cloud accounts the Cloud Profiles category would collect from. + + Its own endpoint rather than a field on ``/config``, because the settings + form needs this before any config exists — ``/config`` answers ``null`` + until the first save, which would leave the toggle disabled during the + very setup it's part of. + """ + bambu, orca = await _count_cloud_accounts(db) + return CloudAccountCounts(bambu=bambu, orca=orca) + + def _config_to_response(config: GitHubBackupConfig) -> dict: """Convert config model to response dict.""" return { diff --git a/backend/app/api/routes/orca_cloud.py b/backend/app/api/routes/orca_cloud.py index 50bbe74d9..b90a6cced 100644 --- a/backend/app/api/routes/orca_cloud.py +++ b/backend/app/api/routes/orca_cloud.py @@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N 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, @@ -440,7 +441,24 @@ async def _build_authenticated_service( 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.""" + 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.") @@ -457,8 +475,11 @@ async def _build_authenticated_service( await svc.refresh() except OrcaCloudAuthError as e: # Refresh token was revoked or rotated out from under us. Clear - # the stale credentials so the UI flips to disconnected. - await _clear_credentials(db, user) + # 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 diff --git a/backend/app/schemas/github_backup.py b/backend/app/schemas/github_backup.py index 51e439cdf..49d053623 100644 --- a/backend/app/schemas/github_backup.py +++ b/backend/app/schemas/github_backup.py @@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel): from_attributes = True +class CloudAccountCounts(BaseModel): + """How many connected cloud accounts a backup would collect presets from. + + Counts only, never identities: with auth enabled these are other users' + accounts, and whoever administers the backup has no business learning who + signed in to what. The number is enough to answer the only question the UI + asks — is the Cloud Profiles category worth offering at all (#2717). + """ + + bambu: int = Field(default=0, description="Connected Bambu Cloud accounts") + orca: int = Field(default=0, description="Connected Orca Cloud accounts") + + class GitHubBackupStatus(BaseModel): """Schema for current backup status.""" diff --git a/backend/app/services/github_backup.py b/backend/app/services/github_backup.py index a8e0043c1..af0f2043e 100644 --- a/backend/app/services/github_backup.py +++ b/backend/app/services/github_backup.py @@ -8,7 +8,7 @@ import logging from datetime import datetime, timedelta, timezone import httpx -from sqlalchemy import desc, select +from sqlalchemy import desc, or_, select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import async_session @@ -18,11 +18,61 @@ from backend.app.models.printer import Printer from backend.app.models.settings import Settings from backend.app.models.spool import Spool from backend.app.models.spool_usage_history import SpoolUsageHistory +from backend.app.models.user import User from backend.app.services.git_providers.factory import get_provider_backend from backend.app.services.printer_manager import printer_manager logger = logging.getLogger(__name__) +# Bambu's listing endpoint is keyed by preset type and calls process presets +# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a +# divergence here silently drops a whole preset type from every backup. +_BAMBU_PRESET_TYPES = { + "filament": "filament", + "printer": "printer", + "print": "process", +} + + +def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict: + """One Bambu preset as stored in the backup: metadata plus the payload. + + ``base_id`` and ``setting`` are the two fields ``BambuCloudService. + create_setting`` needs, so a restore can rebuild the preset rather than + just list it. + + ``user_id`` from the listing is deliberately dropped. It identifies the + account and adds nothing to a rebuild, and backup repositories can be + public. + """ + return { + "setting_id": str(setting_id), + "name": detail.get("name") or entry.get("name") or "Unknown", + "type": our_type, + "version": detail.get("version") or entry.get("version"), + "updated_time": entry.get("updated_time"), + "base_id": detail.get("base_id"), + "filament_id": detail.get("filament_id"), + "setting": detail.get("setting") or {}, + } + + +def _orca_profile_record(entry: dict) -> dict: + """One Orca profile as stored in the backup. + + ``content`` is kept whole rather than picked apart: it is the profile, the + sync API hands it over inline, and Orca owns its shape. Narrowing it here + would mean guessing which keys a future restore needs. + """ + return { + "id": str(entry.get("id")) if entry.get("id") is not None else None, + "name": entry.get("name"), + "updated_time": entry.get("updated_time"), + "created_time": entry.get("created_time"), + "content": entry.get("content"), + } + + # Schedule intervals in seconds SCHEDULE_INTERVALS = { "hourly": 3600, @@ -279,11 +329,13 @@ class GitHubBackupService: { "backup_metadata.json": {...}, "kprofiles/{serial}/{nozzle}.json": {...}, - "cloud_profiles/filament.json": [...], - "cloud_profiles/printer.json": [...], - "cloud_profiles/process.json": [...], + "cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...}, + "cloud_profiles/orca/{account}/{filament,printer,process}.json": {...}, "settings/app_settings.json": {...}, } + + ``{account}`` is ``global`` when auth is disabled, otherwise + ``user-{id}`` — one directory per connected cloud account (#2717). """ files: dict[str, dict | list] = {} @@ -306,10 +358,20 @@ class GitHubBackupService: self._backup_progress = "Collecting K-profiles from printers..." await self._collect_kprofiles(db, files) - # Collect cloud profiles + # Collect cloud profiles. `contents.cloud_profiles` is corrected below + # from what was configured to what was actually written — it claimed + # `true` on every backup, including the ones that collected nothing + # (#2717), which is exactly the signal a restore needs to be able to + # trust. if config.backup_cloud_profiles: - self._backup_progress = "Collecting cloud profiles from Bambu Cloud..." - await self._collect_cloud_profiles(db, files) + self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..." + cloud_summary = await self._collect_cloud_profiles(db, files) + collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca")) + metadata["contents"]["cloud_profiles"] = collected + if collected: + # Per-cloud, per-account counts, so a restore can tell an empty + # account from one that failed to collect. + metadata["cloud_profiles"] = cloud_summary # Collect app settings if config.backup_settings: @@ -374,69 +436,284 @@ class GitHubBackupService: if printer_profiles: logger.info("Collected K-profiles for %s: %s", serial, printer_profiles) - async def _collect_cloud_profiles(self, db: AsyncSession, files: dict): - """Collect Bambu Cloud profiles if authenticated.""" - # Backup runs without a user context, so fall back to the auth-disabled - # Settings storage. ``build_authenticated_cloud`` honours the stored - # region so China-region tokens are validated against api.bambulab.cn. + async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict: + """Collect slicer presets from every connected cloud account. + + Two clouds, and on an auth-enabled install any number of accounts in + each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud + tokens on ``User.orca_cloud_token``, falling back to the global + ``Settings`` table only when auth is disabled. The previous version + asked for the auth-disabled store unconditionally, so it collected + nothing at all on any install with auth on (#2717). + + Layout is one directory per cloud per account, both clouds grouped the + same way so a restore reads them identically:: + + 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. + + Returns a per-cloud summary for ``backup_metadata.json`` so the + metadata records what was actually collected rather than what was + merely enabled. + """ + summary: dict = {"bambu": {}, "orca": {}} + + bambu_accounts, orca_accounts = await self.cloud_accounts(db) + if not bambu_accounts and not orca_accounts: + # Enabled but nothing to collect. Deliberately a warning: the INFO + # line this replaces read as a successful collection of nothing, + # which is how #2717 went unnoticed through every backup. + logger.warning( + "Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud " + "account is connected — nothing to collect." + ) + return summary + + for account_key, user in bambu_accounts: + try: + counts = await self._collect_bambu_profiles(db, files, account_key, user) + except Exception: + logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True) + continue + if counts: + summary["bambu"][account_key] = counts + + for account_key, user in orca_accounts: + try: + counts = await self._collect_orca_profiles(db, files, account_key, user) + except Exception: + logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True) + continue + if counts: + summary["orca"][account_key] = counts + + if not summary["bambu"] and not summary["orca"]: + logger.warning( + "Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, " + "but no presets were collected — see the per-account warnings above.", + len(bambu_accounts), + len(orca_accounts), + ) + else: + logger.info("Collected cloud profiles: %s", summary) + return summary + + async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]: + """Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud. + + With auth enabled every user holds their own credentials, so a backup + that only looked at the global store saw none of them. With auth + disabled there is a single global row and no ``User`` at all, which is + what ``user=None`` means to both clouds' credential loaders. + + Both stores are read regardless: a ``Settings`` row survives enabling + auth later, and dropping it silently would lose that account's presets. + """ + from backend.app.api.routes.cloud import get_stored_token + from backend.app.api.routes.orca_cloud import _load_credentials + + bambu: list = [] + orca: list = [] + + global_token, _email, _region = await get_stored_token(db, None) + if global_token: + bambu.append(("global", None)) + global_orca = await _load_credentials(db, None) + if global_orca.token: + orca.append(("global", None)) + + result = await db.execute( + select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None))) + ) + for user in result.scalars().all(): + if user.cloud_token: + bambu.append((f"user-{user.id}", user)) + if user.orca_cloud_token: + orca.append((f"user-{user.id}", user)) + + return bambu, orca + + async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict: + """Collect one Bambu Cloud account's custom presets, with their payloads. + + The listing endpoint is keyed by preset type, each holding ``private`` + and ``public`` lists — there is no flat ``setting`` array, and the + entries carry no ``type`` of their own, which is why the type comes + from the outer key here exactly as it does in ``routes/cloud.py``. + Bambu calls process presets ``print``. + + ``public`` is skipped: those are Bambu's own bundled catalogue, the + same hundreds of entries for every user, re-downloadable at any time + and not recreatable under your account anyway. Backing them up would + churn the repository on every run for nothing. + + Each private preset then costs one ``get_setting_detail`` call, because + the listing carries only metadata. Without ``base_id`` and ``setting`` + the backup is a list of names, not something a restore can rebuild + from. Bounded by the number of *custom* presets, and the backup already + makes a round-trip per printer for K-profiles. + """ from backend.app.api.routes.cloud import build_authenticated_cloud - cloud = await build_authenticated_cloud(db, user=None) + cloud = await build_authenticated_cloud(db, user=user) if cloud is None or not cloud.is_authenticated: - if cloud is not None: - await cloud.close() - logger.info("Cloud not authenticated, skipping cloud profiles") - return + logger.info("Bambu Cloud not authenticated for %s, skipping", account_key) + return {} + counts: dict = {} try: settings = await cloud.get_slicer_settings() - if not settings: - return + if not isinstance(settings, dict) or not settings: + logger.warning("Bambu Cloud returned no slicer settings for %s", account_key) + return {} - # Separate by type - filament_settings = [] - printer_settings = [] - process_settings = [] + failed = 0 + for api_key, our_type in _BAMBU_PRESET_TYPES.items(): + type_data = settings.get(api_key) + if not isinstance(type_data, dict): + continue + private = type_data.get("private") + if not isinstance(private, list) or not private: + continue - for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []: - setting_type = setting.get("type", "") - if setting_type == "filament": - filament_settings.append(setting) - elif setting_type == "printer": - printer_settings.append(setting) - elif setting_type == "process": - process_settings.append(setting) + profiles = [] + for entry in private: + setting_id = entry.get("setting_id") or entry.get("id") + if not setting_id: + continue + try: + detail = await cloud.get_setting_detail(str(setting_id)) + except Exception as e: + # One unreadable preset must not cost the rest of the + # account, but it must not vanish quietly either. + failed += 1 + logger.warning( + "Failed to fetch Bambu Cloud preset %s (%s) for %s: %s", + setting_id, + entry.get("name", "unnamed"), + account_key, + e, + ) + continue + profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail)) - if filament_settings: - files["cloud_profiles/filament.json"] = { - "version": "1.0", - "profiles": filament_settings, - } + if profiles: + files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = { + "version": "2.0", + "cloud": "bambu", + "type": our_type, + "profiles": profiles, + } + counts[our_type] = len(profiles) - if printer_settings: - files["cloud_profiles/printer.json"] = { - "version": "1.0", - "profiles": printer_settings, - } - - if process_settings: - files["cloud_profiles/process.json"] = { - "version": "1.0", - "profiles": process_settings, - } - - logger.info( - "Collected cloud profiles: %d filament, %d printer, %d process", - len(filament_settings), - len(printer_settings), - len(process_settings), - ) - - except Exception: - logger.warning("Failed to collect cloud profiles", exc_info=True) + if failed: + counts["failed"] = failed + return counts finally: await cloud.close() + async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict: + """Collect one Orca Cloud account's profiles, grouped the same three ways. + + Cheaper than Bambu: the sync-pull listing already carries each + profile's full ``content``, so there is no per-profile fetch. + + The type lives at ``content.type`` and is mapped through the same + ``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups + exactly as the UI does. Where that route *drops* a profile whose type + it can't map, this writes it to ``other.json`` instead — a backup that + silently omits a profile because Orca added a type is the same class of + bug as #2717 itself. + + Uses the route layer's ``_build_authenticated_service`` rather than + re-implementing the refresh: the Orca refresh token is single-use and + rotating, and that helper already persists the new pair atomically + before returning. + + Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the + account instead of disconnecting it. A backup is an observer; it should + not change anyone's sign-in state on a schedule, least of all on a + rejection reason Orca does not disambiguate. The next time the user + opens the Orca Profiles page that route clears the dead pairing anyway, + with the user present to pair again. + """ + from fastapi import HTTPException + + from backend.app.api.routes.orca_cloud import ( + _ORCA_TYPE_TO_BAMBU, + _build_authenticated_service, + ) + + try: + svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False) + except HTTPException as e: + # Either way the stored credentials are untouched and this account + # is skipped, not disconnected — but the two need different advice. + # A rejected refresh will not fix itself and needs the user to pair + # again; an unreachable Orca is very likely gone by the next run. + if e.status_code == 401: + logger.warning( + "Orca Cloud rejected the stored session for %s, so its profiles are not in this " + "backup. Later runs will skip it too until the account is paired again under " + "Profiles > Orca Cloud Profiles — which is also where the dead credentials get " + "cleared. Cause: %s", + account_key, + e.detail, + ) + else: + logger.warning( + "Orca Cloud unreachable for %s, skipping its profiles this run: %s", + account_key, + e.detail, + ) + return {} + except Exception as e: + logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True) + return {} + + counts: dict = {} + try: + raw_profiles = await svc.list_profiles() + grouped: dict[str, list] = {} + unknown_types: dict[str, int] = {} + + for entry in raw_profiles: + if not isinstance(entry, dict): + continue + content = entry.get("content") + raw_type = content.get("type") if isinstance(content, dict) else None + our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None + if our_type is None: + unknown_types[str(raw_type) if raw_type is not None else ""] = ( + unknown_types.get(str(raw_type) if raw_type is not None else "", 0) + 1 + ) + our_type = "other" + grouped.setdefault(our_type, []).append(_orca_profile_record(entry)) + + for our_type, profiles in grouped.items(): + files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = { + "version": "2.0", + "cloud": "orca", + "type": our_type, + "profiles": profiles, + } + counts[our_type] = len(profiles) + + if unknown_types: + logger.warning( + "Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — " + "backed up to other.json rather than dropped.", + sum(unknown_types.values()), + account_key, + unknown_types, + ) + return counts + finally: + await svc.close() + async def _collect_settings(self, db: AsyncSession, files: dict): """Collect app settings.""" result = await db.execute(select(Settings)) diff --git a/backend/tests/unit/test_github_backup_cloud_profiles.py b/backend/tests/unit/test_github_backup_cloud_profiles.py new file mode 100644 index 000000000..03571d02b --- /dev/null +++ b/backend/tests/unit/test_github_backup_cloud_profiles.py @@ -0,0 +1,509 @@ +"""Cloud-profile collection for Git backup (#2717). + +The collector used to read a ``setting`` key the Bambu Cloud API never returns, +so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed +it was. It also asked for the auth-disabled credential store unconditionally, +which meant it saw no accounts at all once auth was on. These tests pin the +response shape it actually has to parse, the account enumeration, and the +metadata now telling the truth. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.models.settings import Settings +from backend.app.models.user import User +from backend.app.services.github_backup import GitHubBackupService + +# The real listing body: keyed by preset type, each holding private/public +# lists. There is no top-level "setting" array, and the entries carry no "type" +# of their own — the type is the outer key, and Bambu calls process "print". +BAMBU_LISTING = { + "filament": { + "private": [ + {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"}, + ], + "public": [ + {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"}, + ], + }, + "printer": { + "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}], + "public": [], + }, + "print": { + "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}], + "public": [], + }, +} + + +def _detail(setting_id: str, name: str, base: str) -> dict: + return { + "setting_id": setting_id, + "name": name, + "type": "filament", + "version": "1.0", + "base_id": base, + "filament_id": "P1234", + "setting": {"filament_flow_ratio": ["0.98"]}, + } + + +def _bambu_cloud(listing=None, detail_side_effect=None): + cloud = MagicMock() + cloud.is_authenticated = True + cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING) + cloud.get_setting_detail = AsyncMock( + side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")), + ) + cloud.close = AsyncMock() + return cloud + + +def _orca_service(profiles): + svc = MagicMock() + svc.list_profiles = AsyncMock(return_value=profiles) + svc.close = AsyncMock() + return svc + + +@pytest.fixture +def service(): + return GitHubBackupService() + + +class TestCloudAccountEnumeration: + """Which accounts a backup collects from.""" + + @pytest.mark.asyncio + async def test_auth_disabled_uses_the_global_store(self, service, db_session): + """With auth off there is no User row at all — credentials live in the + Settings table and the account is keyed ``global``.""" + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=("bambu-token", "a@b.c", "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + bambu, orca = await service.cloud_accounts(db_session) + + assert bambu == [("global", None)] + assert orca == [] + + @pytest.mark.asyncio + async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session): + """The bug that made this invisible: with auth on, tokens live on User + rows, and the collector only ever looked at the global store. Each cloud + is enumerated separately so a user connected to one shows up only there. + """ + both = User(username="both", cloud_token="t1", orca_cloud_token="o1") + bambu_only = User(username="bambu-only", cloud_token="t2") + orca_only = User(username="orca-only", orca_cloud_token="o2") + neither = User(username="neither") + db_session.add_all([both, bambu_only, orca_only, neither]) + await db_session.commit() + + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + bambu, orca = await service.cloud_accounts(db_session) + + assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"] + assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"] + + @pytest.mark.asyncio + async def test_global_and_per_user_accounts_coexist(self, service, db_session): + """A Settings row survives someone enabling auth later. Dropping it + would silently stop backing up that account's presets.""" + db_session.add(User(username="u", cloud_token="t1")) + await db_session.commit() + + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=("legacy-global", None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + bambu, _orca = await service.cloud_accounts(db_session) + + assert "global" in [key for key, _ in bambu] + assert len(bambu) == 2 + + +class TestBambuCollection: + @pytest.mark.asyncio + async def test_reads_the_shape_the_api_actually_returns(self, service, db_session): + """The whole bug in one assertion: presets come out of + ``data[type]["private"]``, not a flat ``setting`` list, and ``print`` + maps to ``process``.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + counts = await service._collect_bambu_profiles(db_session, files, "global", None) + + assert counts == {"filament": 1, "printer": 1, "process": 1} + assert set(files) == { + "cloud_profiles/bambu/global/filament.json", + "cloud_profiles/bambu/global/printer.json", + "cloud_profiles/bambu/global/process.json", + } + + @pytest.mark.asyncio + async def test_public_presets_are_not_backed_up(self, service, db_session): + """Bambu's bundled catalogue is identical for everyone, re-downloadable, + and not recreatable under your account — backing it up would churn the + repository on every run for no recovery value.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + await service._collect_bambu_profiles(db_session, files, "global", None) + + filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"] + assert [p["setting_id"] for p in filament] == ["PFUS1"] + + @pytest.mark.asyncio + async def test_stores_the_payload_a_restore_needs(self, service, db_session): + """The listing is metadata only. Without ``base_id`` and ``setting`` + the backup is a list of names — ``create_setting`` cannot rebuild from + it.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + await service._collect_bambu_profiles(db_session, files, "global", None) + + preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0] + assert preset["base_id"] == "GFSA00" + assert preset["setting"] == {"filament_flow_ratio": ["0.98"]} + assert preset["type"] == "filament" + + @pytest.mark.asyncio + async def test_account_identity_is_not_written_to_the_repo(self, service, db_session): + """Backup repositories can be public, and ``user_id`` adds nothing to a + rebuild.""" + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(), + ): + await service._collect_bambu_profiles(db_session, files, "global", None) + + for payload in files.values(): + for preset in payload["profiles"]: + assert "user_id" not in preset + + @pytest.mark.asyncio + async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session): + """And it is counted, not swallowed — a partial backup that looks + complete is how #2717 stayed invisible.""" + + def detail(setting_id): + if setting_id == "PFUS1": + raise RuntimeError("boom") + return _detail(setting_id, "ok", "GFSA00") + + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=_bambu_cloud(detail_side_effect=detail), + ): + counts = await service._collect_bambu_profiles(db_session, files, "global", None) + + assert "cloud_profiles/bambu/global/filament.json" not in files + assert counts["printer"] == 1 + assert counts["process"] == 1 + assert counts["failed"] == 1 + + @pytest.mark.asyncio + async def test_unauthenticated_account_writes_nothing(self, service, db_session): + files: dict = {} + with patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + return_value=None, + ): + counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock()) + + assert counts == {} + assert files == {} + + +class TestOrcaCollection: + @pytest.mark.asyncio + async def test_groups_by_content_type_including_aliases(self, service, db_session): + """Orca carries the type at ``content.type`` and uses BambuStudio-style + aliases — ``machine`` is a printer, ``process`` and ``print`` are both + process. Same map the Orca tab groups by.""" + profiles = [ + {"id": 1, "name": "f", "content": {"type": "filament"}}, + {"id": 2, "name": "m", "content": {"type": "machine"}}, + {"id": 3, "name": "p", "content": {"type": "print"}}, + {"id": 4, "name": "p2", "content": {"type": "process"}}, + ] + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + return_value=_orca_service(profiles), + ): + counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock()) + + assert counts == {"filament": 1, "printer": 1, "process": 2} + assert "cloud_profiles/orca/user-3/printer.json" in files + + @pytest.mark.asyncio + async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session): + """The sync-pull listing already carries each profile's content, so + unlike Bambu there is no per-profile round trip.""" + svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}]) + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + return_value=svc, + ): + await service._collect_orca_profiles(db_session, files, "global", None) + + stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0] + assert stored["content"] == {"type": "filament", "flow": 0.98} + assert svc.list_profiles.await_count == 1 + + @pytest.mark.asyncio + async def test_unmapped_types_are_kept_not_dropped(self, service, db_session): + """The Orca *route* drops profiles whose type it can't render, which is + right for a list and wrong for a backup: silently omitting a profile + because Orca added a type is the same class of bug as #2717.""" + profiles = [ + {"id": 1, "name": "f", "content": {"type": "filament"}}, + {"id": 2, "name": "x", "content": {"type": "something_new"}}, + {"id": 3, "name": "y", "content": {}}, + ] + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + return_value=_orca_service(profiles), + ): + counts = await service._collect_orca_profiles(db_session, files, "global", None) + + assert counts["other"] == 2 + assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2 + + @pytest.mark.asyncio + async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session): + """An unexpected failure building the Orca client must not abort the + rest of the backup — the other accounts and the other cloud still have + profiles worth collecting.""" + files: dict = {} + with patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + side_effect=RuntimeError("session expired"), + ): + counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock()) + + assert counts == {} + assert files == {} + + @pytest.mark.asyncio + async def test_the_backup_never_disconnects_an_account(self, service, db_session): + """A backup is an observer. It must not change anyone's sign-in state + on a schedule — least of all on Orca's composite rejection reason, + which cannot tell a real revocation from a lost refresh-rotation race. + The Profiles route clears the dead pairing instead, with the user + present to act on it. + """ + from fastapi import HTTPException + + build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used")) + files: dict = {} + with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build): + counts = await service._collect_orca_profiles(db_session, files, "global", None) + + assert counts == {} + assert build.await_args.kwargs["clear_on_auth_failure"] is False + + @pytest.mark.asyncio + async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog): + """Not clearing means the warning recurs every run, so the one line the + operator sees has to say how to stop it.""" + from fastapi import HTTPException + + files: dict = {} + with ( + caplog.at_level("WARNING"), + patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"), + ), + ): + counts = await service._collect_orca_profiles(db_session, files, "global", None) + + assert counts == {} + assert "paired again" in caplog.text + assert "Later runs will skip it too" in caplog.text + + @pytest.mark.asyncio + async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog): + """502 is very likely gone by the next run, so it must not carry the + "go and re-pair" advice a rejected session does.""" + from fastapi import HTTPException + + files: dict = {} + with ( + caplog.at_level("WARNING"), + patch( + "backend.app.api.routes.orca_cloud._build_authenticated_service", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"), + ), + ): + counts = await service._collect_orca_profiles(db_session, files, "user-9", None) + + assert counts == {} + assert "unreachable" in caplog.text + assert "paired again" not in caplog.text + + +class TestCollectorAndMetadata: + @pytest.mark.asyncio + async def test_no_connected_account_collects_nothing(self, service, db_session): + files: dict = {} + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + ): + summary = await service._collect_cloud_profiles(db_session, files) + + assert summary == {"bambu": {}, "orca": {}} + assert files == {} + + @pytest.mark.asyncio + async def test_one_failing_account_does_not_stop_the_others(self, service, db_session): + a = User(username="a", cloud_token="t1") + b = User(username="b", cloud_token="t2") + db_session.add_all([a, b]) + await db_session.commit() + + def build(db, user=None): + if user is not None and user.username == "a": + raise RuntimeError("cloud down for this account") + return _bambu_cloud() + + files: dict = {} + with ( + patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ), + patch( + "backend.app.api.routes.orca_cloud._load_credentials", + new_callable=AsyncMock, + return_value=MagicMock(token=None), + ), + patch( + "backend.app.api.routes.cloud.build_authenticated_cloud", + new_callable=AsyncMock, + side_effect=build, + ), + ): + summary = await service._collect_cloud_profiles(db_session, files) + + assert f"user-{a.id}" not in summary["bambu"] + assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1} + + @pytest.mark.asyncio + async def test_metadata_reports_collection_not_configuration(self, service, db_session): + """``contents.cloud_profiles`` said ``true`` on every backup, including + the ones that wrote nothing. A restore has to be able to trust it.""" + config = MagicMock( + backup_kprofiles=False, + backup_cloud_profiles=True, + backup_settings=False, + backup_spools=False, + backup_archives=False, + ) + with patch.object( + service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}} + ): + files = await service._collect_backup_data(db_session, config) + + assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False + assert "cloud_profiles" not in files["backup_metadata.json"] + + @pytest.mark.asyncio + async def test_metadata_records_per_account_counts_when_collected(self, service, db_session): + config = MagicMock( + backup_kprofiles=False, + backup_cloud_profiles=True, + backup_settings=False, + backup_spools=False, + backup_archives=False, + ) + summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}} + with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary): + files = await service._collect_backup_data(db_session, config) + + assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True + assert files["backup_metadata.json"]["cloud_profiles"] == summary + + +class TestSettingsFallbackIsStillHonoured: + @pytest.mark.asyncio + async def test_global_orca_row_is_discovered(self, service, db_session): + """Orca's auth-disabled fallback lives in the same Settings table as + Bambu's; both stores are read on every run.""" + db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x")) + await db_session.commit() + + with patch( + "backend.app.api.routes.cloud.get_stored_token", + new_callable=AsyncMock, + return_value=(None, None, "global"), + ): + _bambu, orca = await service.cloud_accounts(db_session) + + assert orca == [("global", None)] diff --git a/backend/tests/unit/test_orca_cloud_refresh.py b/backend/tests/unit/test_orca_cloud_refresh.py new file mode 100644 index 000000000..2d75d576d --- /dev/null +++ b/backend/tests/unit/test_orca_cloud_refresh.py @@ -0,0 +1,125 @@ +"""What a rejected Orca Cloud refresh is allowed to do to stored credentials. + +The refresh token is single-use and rotating, and Orca reports every rejection +with one composite reason (``unknown, expired, revoked, or already used``), so +Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may +still clear on that signal — a person is looking at the page and can pair again +— but a background job must not, or an unattended run can destroy a working +pairing (#2717). +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from sqlalchemy import select + +from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service +from backend.app.models.settings import Settings +from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError + + +async def _store_global_credentials(db): + """An auth-disabled install's Orca credentials, expired so the helper + refreshes rather than returning straight away.""" + db.add_all( + [ + Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"), + Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"), + Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"), + Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"), + ] + ) + await db.commit() + + +async def _stored_keys(db) -> set[str]: + result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values())))) + return {s.key for s in result.scalars().all()} + + +def _expired_service(refresh_side_effect=None): + """A service that reports its access token as expired, so the helper takes + the refresh branch.""" + svc = MagicMock() + svc.is_authenticated = False + svc.refresh_token = "oc_ext_rt_old" + svc.set_tokens = MagicMock() + svc.refresh = AsyncMock(side_effect=refresh_side_effect) + svc.access_token = "oc_ext_new" + svc.token_expiry = None + return svc + + +class TestRejectedRefresh: + @pytest.mark.asyncio + async def test_routes_clear_the_dead_pairing_by_default(self, db_session): + """Unchanged behaviour for interactive callers: the page flips to + disconnected while the user is there to pair again.""" + await _store_global_credentials(db_session) + svc = _expired_service(OrcaCloudAuthError("grant already used")) + + with ( + patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await _build_authenticated_service(db_session, None) + + assert exc.value.status_code == 401 + assert await _stored_keys(db_session) == set() + + @pytest.mark.asyncio + async def test_background_callers_leave_the_credentials_alone(self, db_session): + """The whole point of the flag. A scheduled backup that guesses wrong + here destroys a pairing nobody asked it to touch, and the user finds + out when their profiles stop being backed up.""" + await _store_global_credentials(db_session) + svc = _expired_service(OrcaCloudAuthError("grant already used")) + + with ( + patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await _build_authenticated_service(db_session, None, clear_on_auth_failure=False) + + # Still reported as a hard auth failure — the caller has to skip the + # account — but nothing was destroyed on the way out. + assert exc.value.status_code == 401 + assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session) + assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session) + + @pytest.mark.asyncio + async def test_an_unreachable_orca_never_clears_either_way(self, db_session): + """A transport failure says nothing about the credentials' validity.""" + await _store_global_credentials(db_session) + svc = _expired_service(OrcaCloudError("connection reset")) + + with ( + patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc), + pytest.raises(HTTPException) as exc, + ): + await _build_authenticated_service(db_session, None) + + assert exc.value.status_code == 502 + assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session) + + +class TestSuccessfulRefresh: + @pytest.mark.asyncio + async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session): + """Not optional: by the time the refresh succeeds the old token is + consumed, so failing to store the new pair would break a live pairing + for real. The flag suppresses destruction, never persistence. + """ + await _store_global_credentials(db_session) + svc = _expired_service() + svc.refresh_token = "oc_ext_rt_new" + + with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc): + returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False) + + assert returned is svc + result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"])) + assert result.scalar_one().value == "oc_ext_new" + result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"])) + assert result.scalar_one().value == "oc_ext_rt_new" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e0912b1d7..f28bb624c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2652,6 +2652,14 @@ export interface NotificationProviderUpdate { export type ScheduleType = 'hourly' | 'daily' | 'weekly'; export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab'; +/** How many cloud accounts a backup would collect presets from. Counts only — + * with auth enabled the accounts belong to individual users, so the backup + * administrator sees how many, never whose. */ +export interface CloudAccountCounts { + bambu: number; + orca: number; +} + export interface GitHubBackupConfig { id: number; repository_url: string; @@ -6464,6 +6472,9 @@ export const api = { getGitHubBackupConfig: () => request('/github-backup/config'), + getGitHubBackupCloudAccounts: () => + request('/github-backup/cloud-accounts'), + saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) => request('/github-backup/config', { method: 'POST', diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 32f20cd5e..f13a255fd 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -32,6 +32,7 @@ import type { LocalBackupPathCheck, LocalBackupStatus, ScheduleType, + CloudAccountCounts, CloudAuthStatus, Printer, } from '../api/client'; @@ -324,6 +325,24 @@ export function GitHubBackupSettings() { queryFn: api.getCloudStatus, }); + // How many cloud accounts the backup would actually collect from, across + // both Bambu Cloud and Orca Cloud. Not the same question as `cloudStatus`, + // which is only *this viewer's* Bambu sign-in: with auth enabled every user + // holds their own credentials and the backup collects from all of them, so + // an admin who never signed in to Bambu Cloud personally would otherwise see + // the category disabled while there is plenty to back up (#2717). + const { data: cloudAccounts } = useQuery({ + queryKey: ['github-backup-cloud-accounts'], + queryFn: api.getGitHubBackupCloudAccounts, + staleTime: 60_000, + }); + const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0); + // Until the count arrives, fall back to the viewer's own Bambu status rather + // than rendering the toggle as unavailable and making it flicker enabled. + const anyCloudConnected = cloudAccounts + ? connectedCloudAccounts > 0 + : !!cloudStatus?.is_authenticated; + // Fetch printers and their statuses for K-profile availability const { data: printers } = useQuery({ queryKey: ['printers'], @@ -751,18 +770,18 @@ export function GitHubBackupSettings() {

{t('backup.kProfilesDescription')}

-