fix(cloud): stop reporting an expired Bambu Cloud sign-in as connected (issue #2562)

An expired token was indistinguishable from a working one. set_token()
stamped token_expiry = now + 30 days every time a stored token was loaded,
so the expiry reset on every request and is_authenticated could never
return False. /cloud/status answered "connected" for as long as any token
existed, while every cloud call 401'd — and the user was shown Bambu's own
{"error": "Please login."} verbatim.

Bambu is now the authority: /cloud/status validates the token upstream
(cached 5m), and any 401 from any authenticated call durably records the
credential as dead via users.cloud_token_invalid_at, so MakerWorld, cloud
profiles, slicer presets and firmware checks all agree at once. An
unreachable Bambu is treated as unknown, never as expired, so an outage
cannot sign a working session out.

The user-facing message now names the Profiles page, where the Bambu Cloud
sign-in actually lives; the old text pointed at a Settings page that does
not exist. Same stale path corrected in the wiki.
This commit is contained in:
maziggy 2026-07-14 11:29:56 +02:00
parent 5540fba459
commit 09b739b95d
34 changed files with 1147 additions and 78 deletions

View file

@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b2] - Unreleased
### Fixed
- **"Please login." when importing from MakerWorld — while Bambuddy said you were connected to Bambu Cloud** — An expired Bambu Cloud token was indistinguishable from a working one, so the UI reported "Connected as ..." indefinitely while every cloud call was being rejected. The toast you got was Bambu Lab's own words, forwarded verbatim: their 401 body is `{"code":4,"error":"Please login.","message":""}`, and we passed the `error` field straight through — which read as Bambuddy telling you to log in, next to an indicator saying you already were. **The status was never real.** `set_token()` stamped `token_expiry = now + 30 days` *every time a stored token was loaded from the database* — re-derived from the current moment, for a token of entirely unknown age — and `is_authenticated` was "we have a string, and we're not past that expiry". The expiry reset on every request, so the check could never fail. It was a string-presence test wearing an expiry costume, and `/cloud/status` answered `true` for as long as any token existed. Bambu's access token is opaque (no readable claims), Bambu's login response carries no expiry, and Bambuddy discards the `refreshToken` it is handed, so nothing else in the system knew either. When a token lapsed — Bambu's own comment in our code says they last around three months — **every** cloud feature died at once (MakerWorld imports, cloud profiles, slicer presets, firmware checks) with no signal anywhere that a re-login was needed. **Bambu is now the authority.** No expiry is invented. `/cloud/status` asks Bambu whether the token is still accepted, cached for five minutes so the several components polling it don't each pay a round-trip, and any 401 from any authenticated cloud call durably records the credential as dead — so the whole app agrees at once instead of each feature failing separately. An unreachable Bambu, a 5xx, or a Cloudflare challenge is treated as *unknown*, never as *expired*: an outage must not sign a working session out. The Profiles page now explains why the login form is back, MakerWorld says the sign-in expired rather than that one is required, and its import buttons stop pretending they can download. The user-facing message names the **Profiles** page, where the Bambu Cloud sign-in actually lives — the old fallback text pointed at "Settings → Bambu Cloud", which does not exist.
- **Importing from MakerWorld failed on Windows with a certificate error (#2562)** — Paste a MakerWorld URL, click Save, and the import dies with `S3 download failed: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate`. Only native Windows installs are affected; Docker never sees it. The import walks several hosts, and the failure is at the last hop: Bambu Cloud answers the download request with an **AWS presigned URL**, and that one URL is fetched with `urllib` rather than httpx, on purpose — S3 signs the exact query-string bytes, and httpx re-encodes them into a `SignatureDoesNotMatch`. What that swap quietly also changed was the **trust store**. httpx — every other network call in Bambuddy, including the `api.bambulab.com` calls that succeed immediately before this one — verifies against the bundled `certifi` CA bundle. `urllib` verifies against the *operating system's* store, and on Windows the two disagree: Python's `ssl.load_default_certs()` only enumerates the roots already cached in the Windows ROOT store, which Windows fills in lazily through CryptoAPI's auto-update — a mechanism Python never triggers. On a machine where the Amazon root signing the S3 chain has not been cached yet, verification fails with exactly the error above. Linux images ship a complete `ca-certificates` bundle, so the OS store and certifi agree and the bug is invisible there. The S3 hop now verifies against certifi too, so it trusts precisely what the rest of the app already trusts. Verification itself is untouched — the certificate is still checked and the hostname still matched; the fix changes where the CA list comes from, not whether TLS is enforced. The URL still reaches the transport byte-for-byte, so the S3 signature is unaffected, and the no-redirect guard that keeps the download-host allowlist meaningful is unchanged. `certifi` is now an explicit requirement rather than one inherited from httpx, so a future httpx release cannot drop it out from under this import.
- **Prints on a multi-printer farm started one by one, up to an hour apart (#2555, reporter @Maxtrim3D)** — Start a batch across several printers and they trickle out one at a time; the more printers, the worse it gets. Not a misconfiguration, and nothing in the wiki could have helped: the scheduler awaited each dispatch inline in its selection loop, and a dispatch includes the FTP upload. So every printer queued behind every other printer's transfer, even though they are entirely independent machines. **The arithmetic is the whole bug report.** A Bambu printer's FTP server sustains around 150 KB/s — its own SD-card write is the bottleneck, not the network — so the reporter's 41 MB `.3mf` took **254 seconds per printer**, straight from his logs (`40978500 bytes in 254.1s, 157 KB/s`). Nineteen printers in series is roughly **80 minutes** before the last one starts, which is exactly the "up to 1 hour" he reported, and exactly why it got worse the more printers he selected — the delay is linear in fleet size. The logs show the next upload beginning **131 ms** after the previous one finished, back to back, forever. **Uploads to different printers now run concurrently**, capped by a new **Settings → Workflow → Queue & Dispatch → Concurrent Uploads** value (default 4, up to 16; set it to 1 for the old strictly-serial behaviour if your network or host cannot take parallel transfers). Selection is unchanged and still sequential — only the transfers overlap — so every existing gate (busy printers, plate-clear, filament deficit, shortest-job-first, staggered start) behaves exactly as before, and a queue pass still finishes all of its uploads before the next one begins, which is what stops the same still-`pending` row being dispatched twice. FTP work also moves off asyncio's shared default executor onto its own pool: that executor is sized `min(32, cpu_count + 4)` — six threads on a 2-core NAS — and is shared with everything else in the app, so parallel uploads would have parked one thread each, for minutes at a time, and starved unrelated work.
- **A printer that accepted a file but never started printing was retried forever (#2555)** — Surfaced by the same reporter: "I have a printer who, since the morning, still not launch." When a printer takes the file (its `subtask_id` advances) but never actually begins, the start-watchdog waits 270 seconds, reverts the queue item to `pending`, and the next pass re-uploads the entire file and waits it out again — with **no attempt limit**. For a genuinely wedged printer that loop never terminates, and on a farm each lap also consumes an upload slot the other printers are queueing for, so one stuck machine dragged out everybody else's start times. Retrying is right; retrying forever is not. Attempts are now counted on the queue item: the transient causes the watchdog already recovers from (a publish lost on a half-broken MQTT session is fixed by the forced reconnect on the very next try) still get their retries, but after **three** the item is failed with a message pointing at the printer — check its screen for a prompt or error, and check the SD card — rather than being handed back to the queue a fourth time.
- **A queued library print with no readable print time crashed the dispatch — and took the rest of that queue pass down with it (#2555)** — Found while reviewing the above. Starting a print from a library file read `library_file.print_time_seconds`, a column `LibraryFile` does not have (its print time lives in the file's parsed metadata). It only fired when the archive carried no print time of its own — a plain `.gcode`, or a 3MF the parser could not read — and it fired *after* the job had already been sent to the printer, so the print itself ran but the "print started" notification was lost. Worse, the error unwound the whole queue pass: every other printer still waiting to be dispatched on that tick silently missed its turn and had to wait for the next one. It now uses the print time the queue item already caches. The concurrent-dispatch change above independently contains this class of failure — one printer's dispatch blowing up can no longer cancel its siblings' in-flight uploads.

View file

@ -6,12 +6,13 @@ Handles authentication and profile management with Bambu Cloud.
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import (
@ -21,7 +22,7 @@ from backend.app.core.auth import (
require_permission_if_auth_enabled,
security,
)
from backend.app.core.database import get_db
from backend.app.core.database import async_session, get_db
from backend.app.core.permissions import Permission
from backend.app.models.api_key import APIKey
from backend.app.models.settings import Settings
@ -46,6 +47,7 @@ from backend.app.services.bambu_cloud import (
BambuCloudAuthError,
BambuCloudError,
BambuCloudService,
invalidate_validation_cache,
)
from backend.app.utils.filament_ids import filament_id_to_setting_id
@ -167,6 +169,9 @@ router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud
CLOUD_TOKEN_KEY = "bambu_cloud_token"
CLOUD_EMAIL_KEY = "bambu_cloud_email"
CLOUD_REGION_KEY = "bambu_cloud_region"
# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
# an ISO timestamp; absent/empty means "not known to be dead".
CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
def _normalise_region(region: str | None) -> str:
@ -174,6 +179,63 @@ def _normalise_region(region: str | None) -> str:
return region if region in ("global", "china") else "global"
async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
"""Whether the stored Bambu token is known to have been rejected.
Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
cleared on a fresh login/logout. This is the only durable record we have:
Bambu's access token is opaque (no readable expiry) and Bambuddy does not
persist the refresh token, so without this flag a dead credential looks
exactly like a live one.
"""
if user is not None:
return user.cloud_token_invalid_at is not None
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
row = result.scalar_one_or_none()
return bool(row and row.value)
async def mark_cloud_token_invalid(user_id: int | None) -> None:
"""Record that Bambu rejected the stored token.
Opens its own session on purpose. This runs from
``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
is about to fail writing through that route's session would tie the flag
to a transaction the route may still roll back, and the fact that the
credential is dead is true regardless of how the request ends.
Best-effort: a bookkeeping failure must never replace the 401 the caller
actually needs to see.
"""
now = datetime.now(timezone.utc)
try:
async with async_session() as db:
if user_id is not None:
await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
else:
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
row = result.scalar_one_or_none()
if row:
row.value = now.isoformat()
else:
db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
await db.commit()
logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
except Exception:
logger.exception("Could not record the Bambu Cloud token as invalid")
async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
"""Clear the rejected-token flag — called on every fresh login and logout."""
if user is not None:
await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
return
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
row = result.scalar_one_or_none()
if row:
await db.delete(row)
async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
"""Get stored cloud token, email, and region.
@ -202,15 +264,19 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
When a user is provided (auth enabled), stores on the user record.
When user is None (auth disabled), stores in global Settings table.
Always clears the rejected-token flag: this is a *fresh* credential, and
leaving the flag set would report the new sign-in as expired.
"""
region = _normalise_region(region)
invalidate_validation_cache(token)
if user is not None:
# User object is from the auth dependency's session (detached),
# so use a direct UPDATE via the route's db session.
from sqlalchemy import update
await db.execute(
update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
update(User)
.where(User.id == user.id)
.values(cloud_token=token, cloud_email=email, cloud_region=region, cloud_token_invalid_at=None)
)
await db.commit()
return
@ -223,6 +289,7 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
setting.value = value
else:
db.add(Settings(key=key, value=value))
await _clear_cloud_token_invalid(db, None)
await db.commit()
@ -231,19 +298,29 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
When a user is provided (auth enabled), clears that user's credentials.
When user is None (auth disabled), clears from global Settings table.
"""
if user is not None:
from sqlalchemy import update
The rejected-token flag goes with the token: once there is no credential,
"the credential is dead" is not a state worth remembering, and leaving it
behind would make the next login look expired the moment it is stored.
"""
token, _email, _region = await get_stored_token(db, user)
if token:
invalidate_validation_cache(token)
if user is not None:
await db.execute(
update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
update(User)
.where(User.id == user.id)
.values(cloud_token=None, cloud_email=None, cloud_region=None, cloud_token_invalid_at=None)
)
await db.commit()
return
# Fallback: global storage (auth disabled)
result = await db.execute(
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
select(Settings).where(
Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY, CLOUD_TOKEN_INVALID_KEY])
)
)
for setting in result.scalars().all():
await db.delete(setting)
@ -347,11 +424,17 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
Returns ``None`` when no token is stored, so callers can 401 without constructing
(and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
The service is wired to persist a rejected-token flag the moment Bambu
answers 401, so every route that builds a client this way makes the whole
app agree the sign-in is dead rather than each feature discovering it
separately and reporting Bambu's own opaque "Please login." at the user.
"""
token, _email, region = await get_stored_token(db, user)
if not token:
return None
cloud = BambuCloudService(region=region)
user_id = user.id if user is not None else None
cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
cloud.set_token(token)
return cloud
@ -363,27 +446,55 @@ async def get_auth_status(
):
"""Get current cloud authentication status.
Reads the stored credentials in one DB round-trip (we used to call
``get_stored_token`` twice once here and once inside
``build_authenticated_cloud``). ``region`` is exposed so the frontend can
show "Connected (China)" after a reload without relying on local state.
"We hold a token" is not the same claim as "Bambu accepts it", and this
endpoint used to make the former while reporting the latter: it asked
``cloud.is_authenticated``, which was a string-presence check behind a
self-renewing expiry, so it answered ``true`` for as long as any token
existed including tokens Bambu had been rejecting for months (#2562
follow-up). It now asks Bambu.
The verdict is cached for five minutes inside the service, so the several
components polling this endpoint don't each pay a round-trip. When Bambu
can't be reached the answer is ``None`` and we report the last known state
rather than signing the user out over a transient outage.
``region`` is exposed so the frontend can show "Connected (China)" after a
reload without relying on local state.
"""
token, email, region = await get_stored_token(db, current_user)
if not token:
return CloudAuthStatus(is_authenticated=False, email=None, region=None)
return CloudAuthStatus(is_authenticated=False, email=None, region=None, sign_in_expired=False)
cloud = BambuCloudService(region=region)
known_invalid = await is_cloud_token_invalid(db, current_user)
user_id = current_user.id if current_user is not None else None
cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
cloud.set_token(token)
try:
authenticated = cloud.is_authenticated
return CloudAuthStatus(
is_authenticated=authenticated,
email=email if authenticated else None,
region=region if authenticated else None,
)
if known_invalid:
# Already recorded as dead. Don't re-ask Bambu on every poll — only a
# new login can change this, and that clears the flag.
accepted: bool | None = False
else:
accepted = await cloud.validate_token()
finally:
await cloud.close()
if accepted is None:
# Bambu unreachable / 5xx / Cloudflare challenge. Report what we last
# knew — a cloud outage must not present as "your sign-in expired".
accepted = not known_invalid
return CloudAuthStatus(
is_authenticated=bool(accepted),
email=email if accepted else None,
region=region if accepted else None,
# Distinguishes "you were signed in and the token died" from "you never
# signed in" — the UI shows the same login form either way, but only the
# former deserves an explanation for why it reappeared.
sign_in_expired=not accepted,
)
@router.post("/login", response_model=CloudLoginResponse)
async def login(

View file

@ -21,7 +21,12 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
from backend.app.api.routes.cloud import (
get_stored_token,
is_cloud_token_invalid,
mark_cloud_token_invalid,
resolve_api_key_cloud_owner,
)
from backend.app.api.routes.library import save_3mf_bytes_to_library
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
@ -58,10 +63,16 @@ async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldServi
stored Bambu Cloud bearer token when available.
Mirrors ``cloud.build_authenticated_cloud`` the token is entirely
optional; anonymous calls (metadata, URL resolution) still work.
optional; anonymous calls (metadata, URL resolution) still work and,
like it, records a rejected token so the whole app agrees the sign-in is
dead rather than each feature failing on its own.
"""
token, _email, _region = await get_stored_token(db, user)
return MakerWorldService(auth_token=token)
user_id = user.id if user is not None else None
return MakerWorldService(
auth_token=token,
on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
)
def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
@ -156,7 +167,16 @@ async def get_status(
cloud_token_user = current_user or api_key_cloud_owner
token, _email, _region = await get_stored_token(db, cloud_token_user)
has_token = bool(token)
return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
# A token Bambu has already rejected downloads nothing. ``can_download``
# used to be a bare alias for ``has_cloud_token``, so the import button
# stayed enabled against a dead credential and the user found out via a
# 401 toast (#2562 follow-up).
expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
return MakerWorldStatus(
has_cloud_token=has_token,
can_download=has_token and not expired,
sign_in_expired=expired,
)
@router.post("/resolve", response_model=MakerWorldResolvedModel)

View file

@ -3246,6 +3246,16 @@ async def run_migrations(conn):
else:
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
# Migration: record when Bambu rejects a stored cloud token. Until now the
# only state we kept was the token string itself, so a dead credential was
# indistinguishable from a live one and the UI reported "connected" forever
# while every cloud call 401'd. DATETIME is SQLite-only — Postgres uses
# TIMESTAMP, so the column is dialect-branched per project convention.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token_invalid_at DATETIME")
else:
await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS cloud_token_invalid_at TIMESTAMP")
# Data migration: drop the embedded 3MF Title (`print_name`) from library
# file metadata so the FileManager displays the filename, not the title (#1489).
await _migrate_drop_library_print_name(conn)

View file

@ -44,6 +44,14 @@ class User(Base):
cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
# "global" or "china"; NULL treated as "global" for legacy rows.
cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
# Set when Bambu answers 401 to a call made with ``cloud_token`` — the token
# has expired or been revoked. NULL means "not known to be dead". The token
# itself is kept: clearing it would lose the email/region we show on the
# re-login form, and a token can only be replaced by signing in again anyway.
# Bambu's token is opaque and carries no expiry we can read, and Bambuddy
# does not persist the refresh token, so this flag is the *only* record that
# a stored credential has stopped working (#2562 follow-up).
cloud_token_invalid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
# Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
# with short-lived access tokens (1h) and rotating single-use refresh tokens,

View file

@ -38,6 +38,10 @@ class CloudAuthStatus(BaseModel):
is_authenticated: bool
email: str | None = None
region: Region | None = None
# True when a token is stored but Bambu no longer accepts it. Both this and
# "never signed in" render the login form, but only this one warrants
# telling the user why it came back.
sign_in_expired: bool = False
class CloudTokenRequest(BaseModel):

View file

@ -109,3 +109,7 @@ class MakerWorldStatus(BaseModel):
has_cloud_token: bool = Field(description="Whether the caller's account has a stored Bambu Cloud token")
can_download: bool = Field(description="Shortcut: has_cloud_token AND it looks valid. Downloads require it.")
sign_in_expired: bool = Field(
default=False,
description="A token is stored but Bambu has rejected it — the user must sign in to Bambu Cloud again.",
)

View file

@ -4,8 +4,11 @@ Bambu Lab Cloud API Service
Handles authentication and profile management with Bambu Lab's cloud services.
"""
import hashlib
import logging
from datetime import datetime, timedelta, timezone
import time
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
import httpx
@ -14,6 +17,34 @@ logger = logging.getLogger(__name__)
BAMBU_API_BASE = "https://api.bambulab.com"
BAMBU_API_BASE_CN = "https://api.bambulab.cn"
# How long a "Bambu still accepts this token" answer is trusted before we ask
# again. ``/cloud/status`` is polled by several components, so validating on
# every call would put a Bambu round-trip behind every settings render; a token
# does not expire on a five-minute boundary, so caching that long is free.
_VALIDATION_TTL_SECONDS = 300
# token digest -> (monotonic deadline, accepted?). Keyed by digest so a token
# never sits in a process-wide dict in the clear.
_validation_cache: dict[str, tuple[float, bool]] = {}
def _token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def invalidate_validation_cache(token: str | None = None) -> None:
"""Drop cached validation verdicts.
Called on login/logout so a fresh token isn't judged by the previous one's
cached verdict, and so a re-login clears a cached rejection immediately
rather than leaving the user staring at "sign-in expired" for five minutes.
"""
if token is None:
_validation_cache.clear()
else:
_validation_cache.pop(_token_digest(token), None)
# Client identity sent to Bambu Lab's cloud services. We identify honestly as
# Bambuddy — the URL in parens makes the source unambiguous so Bambu can
# distinguish our traffic from impersonators. This is the opposite of what the
@ -116,11 +147,23 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
class BambuCloudService:
"""Service for interacting with Bambu Lab Cloud API."""
def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
def __init__(
self,
region: str = "global",
client: httpx.AsyncClient | None = None,
on_auth_failure: Callable[[], Awaitable[None]] | None = None,
):
self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
self.access_token: str | None = None
self.refresh_token: str | None = None
self.token_expiry: datetime | None = None
# Fired once when Bambu answers 401 to a call we made with a stored
# token — the credential is dead and the caller wants to record that.
# ``build_authenticated_cloud`` wires this to the persisted flag, so
# every route that builds a service through it gets invalidation for
# free rather than each one having to notice 401s for itself.
self._on_auth_failure = on_auth_failure
self._auth_failure_reported = False
# Prefer an explicitly-injected client (tests), else fall back to the
# app-scoped shared client (production), and finally create our own so
# scripts / tests that skip the lifespan still get a working service.
@ -136,11 +179,94 @@ class BambuCloudService:
@property
def is_authenticated(self) -> bool:
"""Check if we have a valid token."""
"""Whether a credential is *loaded* — NOT whether Bambu accepts it.
Bambu's access token is opaque (no JWT claims to read an expiry out
of), so the only authority on whether it still works is Bambu. This
used to pretend otherwise: ``set_token`` stamped ``token_expiry =
now + 30 days`` every time a stored token was loaded, which made the
expiry check reset on every request and this property incapable of
ever returning False. The UI reported "connected" indefinitely while
every cloud call 401'd (#2562 follow-up).
``token_expiry`` is now only set when we genuinely know it. Callers
that need to know the token still *works* must ask Bambu see
:meth:`validate_token` or react to the 401 that surfaces.
"""
if not self.access_token:
return False
return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
async def _note_response(self, response: httpx.Response) -> None:
"""Record a 401 from Bambu as "this stored credential is dead".
Bambu answers an expired/revoked token with 401 and a body of
``{"code":4,"error":"Please login.","message":""}``. Reported at most
once per service instance so a route that makes several calls doesn't
write the flag several times.
"""
if response.status_code != 401 or self._on_auth_failure is None or self._auth_failure_reported:
return
self._auth_failure_reported = True
if self.access_token:
_validation_cache[_token_digest(self.access_token)] = (
time.monotonic() + _VALIDATION_TTL_SECONDS,
False,
)
try:
await self._on_auth_failure()
except Exception:
# Recording the failure is best-effort — the caller still needs the
# real error (a 401) rather than a bookkeeping exception on top.
logger.exception("Failed to record Bambu Cloud auth failure")
async def validate_token(self) -> bool | None:
"""Ask Bambu whether the loaded token is still accepted.
``True`` accepted, ``False`` rejected (401), ``None`` unknown Bambu
was unreachable or answered 5xx.
``None`` must never be treated as "invalid": a Bambu outage or a
Cloudflare interstitial would otherwise sign every user out of a
perfectly good session. Callers report their last known state instead.
"""
if not self.access_token:
return False
digest = _token_digest(self.access_token)
cached = _validation_cache.get(digest)
if cached and cached[0] > time.monotonic():
return cached[1]
try:
response = await self._client.get(
f"{self.base_url}/v1/design-user-service/my/preference",
headers=self._get_headers(),
timeout=15.0,
)
except httpx.HTTPError as exc:
logger.info("Could not reach Bambu Cloud to validate the stored token: %s", exc)
return None
if response.status_code == 401:
await self._note_response(response)
return False
if response.status_code >= 500:
logger.info(
"Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
)
return None
if response.status_code != 200:
# 4xx that isn't 401 (403, 418 Cloudflare challenge, 429): the token
# itself was not rejected, so don't declare it dead.
logger.info(
"Bambu Cloud returned %s while validating the token — treating as unknown", response.status_code
)
return None
_validation_cache[digest] = (time.monotonic() + _VALIDATION_TTL_SECONDS, True)
return True
def _get_headers(self) -> dict:
"""Get headers for authenticated requests."""
headers = {
@ -313,9 +439,10 @@ class BambuCloudService:
if response.status_code == 200 and access_token:
self.access_token = access_token
self.refresh_token = data.get("refreshToken")
from datetime import datetime, timedelta, timezone
self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
# Expiry left unset: Bambu does not tell us when the token dies
# and the token is opaque, so any value here would be invented.
self.token_expiry = None
invalidate_validation_cache(access_token)
return {"success": True, "message": "Login successful"}
# Provide helpful error message
@ -333,16 +460,30 @@ class BambuCloudService:
return {"success": False, "message": f"TOTP verification error: {e}"}
def _set_tokens(self, data: dict):
"""Set tokens from login response."""
"""Set tokens from a login response.
No expiry is recorded. Bambu's login response carries no expiry, and
the access token is opaque, so the old ``now + 30 days`` was a guess
that outlived its own accuracy see :attr:`is_authenticated`.
"""
self.access_token = data.get("accessToken")
self.refresh_token = data.get("refreshToken")
# Token typically valid for ~3 months, but we'll refresh more often
self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
self.token_expiry = None
if self.access_token:
invalidate_validation_cache(self.access_token)
def set_token(self, access_token: str):
"""Set access token directly (for stored tokens)."""
"""Load a stored access token.
This used to stamp ``token_expiry = now + 30 days`` re-derived from
*now* on every request, for a token of entirely unknown age. That made
``is_authenticated`` a permanent True and is why Bambuddy went on
reporting "connected" long after Bambu had stopped accepting the token.
A stored token's remaining life is unknowable from the token alone, so
we record no expiry and let Bambu be the authority.
"""
self.access_token = access_token
self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
self.token_expiry = None
def logout(self):
"""Clear authentication state."""
@ -391,6 +532,7 @@ class BambuCloudService:
data = response.json()
await self._note_response(response)
if response.status_code == 200:
return data
@ -411,6 +553,7 @@ class BambuCloudService:
params={"version": _SLICER_API_VERSION},
)
await self._note_response(response)
if response.status_code == 200:
return response.json()
@ -464,6 +607,7 @@ class BambuCloudService:
data = response.json()
await self._note_response(response)
if response.status_code in (200, 201):
return data
@ -549,6 +693,7 @@ class BambuCloudService:
data = response.json()
await self._note_response(response)
if response.status_code == 200:
return data
@ -578,6 +723,7 @@ class BambuCloudService:
params={"version": _SLICER_API_VERSION},
)
await self._note_response(response)
if response.status_code in (200, 204):
return {"success": True, "message": "Setting deleted"}
@ -598,6 +744,7 @@ class BambuCloudService:
f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
)
await self._note_response(response)
if response.status_code == 200:
return response.json()
@ -626,6 +773,7 @@ class BambuCloudService:
params={"device_id": device_id},
)
await self._note_response(response)
if response.status_code == 200:
data = response.json()
# API wraps response in 'data' field

View file

@ -20,9 +20,12 @@ from __future__ import annotations
import asyncio
import logging
import re
import ssl
from collections.abc import Awaitable, Callable
from typing import Any
from urllib.parse import urlparse
import certifi
import httpx
logger = logging.getLogger(__name__)
@ -58,6 +61,18 @@ _CLIENT_HEADERS = {
"Referer": "https://makerworld.com/",
}
# Shown whenever Bambu rejects the stored bearer. Bambu's own 401 body is
# ``{"code":4,"error":"Please login.","message":""}`` and we used to forward that
# string verbatim, which surfaced as a "Please login." toast on a UI that was
# simultaneously reporting the user as connected — maximally confusing, and it
# named no page to go to. Say what happened and where to fix it. Bambu Cloud
# sign-in lives on the Profiles page (ProfilesPage.tsx, "Cloud Profiles" tab);
# there is no Settings → Bambu Cloud page, which is what the old fallback text
# told people to look for.
_SIGN_IN_EXPIRED_MESSAGE = (
"Your Bambu Cloud sign-in has expired. Open the Profiles page and sign in to Bambu Cloud again."
)
_MODEL_ID_RE = re.compile(r"/models/(\d+)")
_PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
_MAX_3MF_BYTES = 200 * 1024 * 1024 # 200 MB hard cap
@ -77,6 +92,27 @@ _REFUSED_THUMBNAIL_MIMES = ("text/html", "text/plain", "application/json")
_shared_http_client: httpx.AsyncClient | None = None
def _s3_ssl_context() -> ssl.SSLContext:
"""Build the TLS context used for the S3 presigned download (#2562).
``urllib.request`` verifies against the *OS* trust store, while httpx
every other network call in Bambuddy verifies against the bundled
``certifi`` CA bundle. On Windows those two disagree: Python's
``ssl.load_default_certs()`` only enumerates the roots already cached in
the Windows ROOT store, and Windows populates that store lazily via
CryptoAPI's auto-update, which Python never triggers. If the Amazon root
signing the S3 chain isn't cached on that machine yet, verification fails
with ``unable to get local issuer certificate`` even though the
api.bambulab.com calls that preceded it (httpx) succeeded.
Pinning urllib to certifi makes the S3 hop trust exactly what the rest of
the app already trusts. Built per call rather than at import so a certifi
refresh doesn't require a restart; construction is cheap relative to the
download that follows.
"""
return ssl.create_default_context(cafile=certifi.where())
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
"""Register an app-scoped ``httpx.AsyncClient`` for service reuse.
@ -127,7 +163,7 @@ async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes,
Runs the blocking urllib call in a thread executor so we don't stall
the event loop.
"""
from urllib.request import HTTPRedirectHandler, Request, build_opener
from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
# Don't follow redirects: the host allowlist above is only enforced on
# the initial URL. A 302 from S3 to any other host would otherwise
@ -136,7 +172,9 @@ async def _download_s3_urllib(url: str, filename_fallback: str) -> tuple[bytes,
def redirect_request(self, *args, **kwargs): # type: ignore[override]
return None
opener = build_opener(_NoRedirect)
# HTTPSHandler swaps only the TLS context — the URL still reaches the
# transport verbatim, which is what the S3 signature depends on.
opener = build_opener(_NoRedirect, HTTPSHandler(context=_s3_ssl_context()))
def _blocking_fetch() -> bytes:
req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
@ -195,7 +233,13 @@ class MakerWorldService:
self,
client: httpx.AsyncClient | None = None,
auth_token: str | None = None,
on_auth_failure: Callable[[], Awaitable[None]] | None = None,
):
# Fired when Bambu rejects the stored token (401). MakerWorld runs on the
# same Bambu Cloud bearer as everything else, so a rejection here means
# the credential is dead app-wide — see ``build_authenticated_cloud``.
self._on_auth_failure = on_auth_failure
self._auth_failure_reported = False
if client is not None:
self._client = client
self._owns_client = False
@ -211,6 +255,16 @@ class MakerWorldService:
if self._owns_client:
await self._client.aclose()
async def _note_auth_failure(self) -> None:
"""Record that Bambu rejected the token we sent. Best-effort, once."""
if self._on_auth_failure is None or self._auth_failure_reported:
return
self._auth_failure_reported = True
try:
await self._on_auth_failure()
except Exception:
logger.exception("Failed to record Bambu Cloud auth failure from MakerWorld")
def _headers(self) -> dict[str, str]:
headers = dict(_CLIENT_HEADERS)
if self._auth_token:
@ -249,8 +303,13 @@ class MakerWorldService:
# because the UI remedy is completely different: 401 → re-login,
# 403 → user has to go to MakerWorld and meet the access requirement.
if response.status_code == 401:
upstream = _extract_upstream_error(response)
raise MakerWorldAuthError(upstream or f"MakerWorld rejected the Bambu Cloud token for {path}")
if self._auth_token:
# We sent a token and Bambu refused it — the credential is dead,
# not merely absent. Record that before raising so the rest of the
# app stops claiming the user is connected.
await self._note_auth_failure()
raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
raise MakerWorldAuthError(f"Signing in to Bambu Cloud is required for {path}")
if response.status_code == 403:
upstream = _extract_upstream_error(response)
raise MakerWorldForbiddenError(
@ -410,10 +469,8 @@ class MakerWorldService:
raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
if response.status_code == 401:
upstream = _extract_upstream_error(response)
raise MakerWorldAuthError(
upstream or "Bambu Lab rejected the token — sign in again in Settings → Bambu Cloud"
)
await self._note_auth_failure()
raise MakerWorldAuthError(_SIGN_IN_EXPIRED_MESSAGE)
if response.status_code == 403:
upstream = _extract_upstream_error(response)
raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")

View file

@ -583,11 +583,22 @@ class TestCloudRouteRegionPlumbing:
@pytest.mark.integration
async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
"""GET /cloud/status returns the stored region so the UI can render
'Connected (China)' after a reload."""
'Connected (China)' after a reload.
``validate_token`` is stubbed because the endpoint now asks Bambu whether
the stored token is still accepted rather than assuming it is without
the stub this test would make a live call to api.bambulab.cn with a fake
token, get a 401, and correctly report the session as expired. Region
plumbing is what's under test here.
"""
from backend.app.api.routes.cloud import store_token
from backend.app.core.database import async_session
from backend.app.services.bambu_cloud import BambuCloudService
with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
with (
patch("backend.app.core.auth.is_auth_enabled", return_value=False),
patch.object(BambuCloudService, "validate_token", AsyncMock(return_value=True)),
):
async with async_session() as db:
await store_token(db, "cn-token", "token-auth", "china", user=None)

View file

@ -119,7 +119,7 @@ class TestStatusEndpoint:
headers={"X-API-Key": key},
)
assert resp.status_code == 200, resp.text
assert resp.json() == {"has_cloud_token": True, "can_download": True}
assert resp.json() == {"has_cloud_token": True, "can_download": True, "sign_in_expired": False}
@pytest.mark.asyncio
@pytest.mark.integration
@ -143,7 +143,7 @@ class TestStatusEndpoint:
headers={"X-API-Key": key},
)
assert resp.status_code == 200
assert resp.json() == {"has_cloud_token": False, "can_download": False}
assert resp.json() == {"has_cloud_token": False, "can_download": False, "sign_in_expired": False}
class TestResolveEndpoint:
@ -299,4 +299,4 @@ class TestJwtPathUnchanged:
headers={"Authorization": f"Bearer {admin_token}"},
)
assert resp.status_code == 200
assert resp.json() == {"has_cloud_token": True, "can_download": True}
assert resp.json() == {"has_cloud_token": True, "can_download": True, "sign_in_expired": False}

View file

@ -154,7 +154,10 @@ class TestGetDesign:
await service.get_design(404)
@pytest.mark.asyncio
async def test_maps_401_to_auth_error(self, service):
async def test_maps_401_without_token_to_auth_error(self, service):
"""No token was sent, so a 401 means "sign-in required" — not "your
sign-in expired", and nothing gets marked dead (there is nothing to
mark). The fixture's service carries no auth token."""
resp = MagicMock()
resp.status_code = 401
resp.json.return_value = {"code": 1, "error": "Please log in"}
@ -162,8 +165,39 @@ class TestGetDesign:
with pytest.raises(MakerWorldAuthError) as exc_info:
await service.get_design(1)
# Upstream's own message is surfaced to the caller
assert "Please log in" in str(exc_info.value)
assert "Bambu Cloud" in str(exc_info.value)
@pytest.mark.asyncio
async def test_401_with_token_reports_expired_and_hides_upstream_text(self):
"""Bambu answers a dead token with ``{"error": "Please login."}``. We used
to forward that verbatim, which produced a "Please login." toast on a UI
that simultaneously claimed the user was connected, and pointed at a
Settings page that does not exist. Say what happened, name a real page,
and record the credential as dead."""
marked: list[bool] = []
async def _on_auth_failure() -> None:
marked.append(True)
svc = MakerWorldService(
client=MagicMock(spec=httpx.AsyncClient),
auth_token="tok-abc",
on_auth_failure=_on_auth_failure,
)
svc._client.get = AsyncMock()
resp = MagicMock()
resp.status_code = 401
resp.json.return_value = {"code": 4, "error": "Please login.", "message": ""}
svc._client.get.return_value = resp
with pytest.raises(MakerWorldAuthError) as exc_info:
await svc.get_design(1)
message = str(exc_info.value)
assert "Please login." not in message
assert "expired" in message.lower()
assert "Profiles" in message
assert marked == [True], "a rejected token must be recorded as dead"
@pytest.mark.asyncio
async def test_maps_403_to_forbidden_with_upstream_reason(self, service):

View file

@ -0,0 +1,317 @@
"""Tests for Bambu Cloud sign-in expiry detection.
Bambu's access token is opaque — no readable expiry — and Bambuddy does not
persist the refresh token, so the only authority on whether a stored token still
works is Bambu itself. Bambuddy used to pretend otherwise: ``set_token()``
stamped ``token_expiry = now + 30 days`` *every time a stored token was loaded*,
which reset the expiry check on every request and made ``is_authenticated``
incapable of ever returning False. ``/cloud/status`` therefore reported
"connected" indefinitely while every cloud call 401'd, and the user was shown
Bambu's own ``{"error": "Please login."}`` as a toast — on a UI that was
simultaneously telling them they were signed in.
These tests pin: the expiry is no longer invented, a 401 is recorded durably,
a Bambu outage does not masquerade as an expired sign-in, and a fresh login
clears the flag.
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from backend.app.api.routes.cloud import (
CLOUD_EMAIL_KEY,
CLOUD_REGION_KEY,
CLOUD_TOKEN_INVALID_KEY,
CLOUD_TOKEN_KEY,
clear_token,
is_cloud_token_invalid,
store_token,
)
from backend.app.models.settings import Settings
from backend.app.services import bambu_cloud as bc
from backend.app.services.bambu_cloud import BambuCloudService
@pytest.fixture(autouse=True)
def _clear_validation_cache():
"""The validation verdict cache is module-level; don't leak across tests."""
bc.invalidate_validation_cache()
yield
bc.invalidate_validation_cache()
def _service(status_code: int = 200, *, on_auth_failure=None, raises: Exception | None = None):
svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient), on_auth_failure=on_auth_failure)
resp = MagicMock()
resp.status_code = status_code
svc._client.get = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=resp)
return svc
class TestNoInventedExpiry:
def test_set_token_records_no_expiry(self):
"""The bug in one line: this used to be ``now + 30 days``, re-derived on
every request from a token of entirely unknown age."""
svc = _service()
svc.set_token("stored-token-of-unknown-age")
assert svc.token_expiry is None
def test_is_authenticated_means_loaded_not_accepted(self):
"""It still answers True for a loaded token — that is all it ever knew.
The point is that nobody may now read it as "Bambu accepts this"."""
svc = _service()
assert svc.is_authenticated is False
svc.set_token("stored-token")
assert svc.is_authenticated is True
class TestValidateToken:
@pytest.mark.asyncio
async def test_accepted_token_returns_true(self):
svc = _service(200)
svc.set_token("good-token")
assert await svc.validate_token() is True
@pytest.mark.asyncio
async def test_rejected_token_returns_false(self):
svc = _service(401)
svc.set_token("dead-token")
assert await svc.validate_token() is False
@pytest.mark.asyncio
async def test_no_token_is_not_authenticated(self):
svc = _service(200)
assert await svc.validate_token() is False
@pytest.mark.asyncio
async def test_network_failure_is_unknown_not_invalid(self):
"""A Bambu outage must never present as "your sign-in expired" — that
would sign every user out of a perfectly good session."""
svc = _service(raises=httpx.ConnectError("no route to host"))
svc.set_token("good-token")
assert await svc.validate_token() is None
@pytest.mark.asyncio
async def test_server_error_is_unknown_not_invalid(self):
svc = _service(503)
svc.set_token("good-token")
assert await svc.validate_token() is None
@pytest.mark.asyncio
async def test_cloudflare_challenge_is_unknown_not_invalid(self):
"""418/403 from Bambu's anti-abuse edge means the *request* was refused,
not the token. Declaring the credential dead there would log users out
whenever Cloudflare gets suspicious of their IP."""
svc = _service(418)
svc.set_token("good-token")
assert await svc.validate_token() is None
@pytest.mark.asyncio
async def test_verdict_is_cached(self):
"""/cloud/status is polled by several components; without the cache each
render would put a Bambu round-trip in front of the settings page."""
svc = _service(200)
svc.set_token("good-token")
assert await svc.validate_token() is True
assert await svc.validate_token() is True
assert svc._client.get.await_count == 1
@pytest.mark.asyncio
async def test_cache_is_keyed_per_token(self):
svc = _service(200)
svc.set_token("token-a")
assert await svc.validate_token() is True
other = _service(401)
other.set_token("token-b")
assert await other.validate_token() is False, "a different token must not inherit the cached verdict"
@pytest.mark.asyncio
async def test_login_drops_a_cached_rejection(self):
"""Re-login must not leave the user staring at "sign-in expired" for the
rest of the cache TTL."""
svc = _service(401)
svc.set_token("tok")
assert await svc.validate_token() is False
fresh = _service(200)
fresh._set_tokens({"accessToken": "tok"}) # same string, freshly minted upstream
assert await fresh.validate_token() is True
class TestAuthFailureCallback:
@pytest.mark.asyncio
async def test_401_fires_the_callback(self):
calls: list[int] = []
async def _cb() -> None:
calls.append(1)
svc = _service(401, on_auth_failure=_cb)
svc.set_token("dead-token")
await svc.validate_token()
assert calls == [1]
@pytest.mark.asyncio
async def test_reported_once_per_service(self):
"""A route that makes several cloud calls must not write the flag once
per call."""
calls: list[int] = []
async def _cb() -> None:
calls.append(1)
svc = _service(401, on_auth_failure=_cb)
svc.set_token("dead-token")
resp = MagicMock()
resp.status_code = 401
await svc._note_response(resp)
await svc._note_response(resp)
await svc._note_response(resp)
assert calls == [1]
@pytest.mark.asyncio
async def test_success_does_not_fire_the_callback(self):
calls: list[int] = []
async def _cb() -> None:
calls.append(1)
svc = _service(200, on_auth_failure=_cb)
svc.set_token("good-token")
await svc.validate_token()
assert calls == []
@pytest.mark.asyncio
async def test_callback_failure_does_not_mask_the_401(self):
"""Recording the dead credential is bookkeeping. If it throws, the caller
must still get the auth failure it was actually waiting for."""
async def _cb() -> None:
raise RuntimeError("database is on fire")
svc = _service(401, on_auth_failure=_cb)
svc.set_token("dead-token")
assert await svc.validate_token() is False
class TestPersistedFlag:
"""Auth-disabled deployments keep cloud credentials in the Settings table."""
@pytest.mark.asyncio
async def test_absent_by_default(self, db_session):
assert await is_cloud_token_invalid(db_session, None) is False
@pytest.mark.asyncio
async def test_set_flag_is_read_back(self, db_session):
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=datetime.now(timezone.utc).isoformat()))
await db_session.commit()
assert await is_cloud_token_invalid(db_session, None) is True
@pytest.mark.asyncio
async def test_fresh_login_clears_the_flag(self, db_session):
"""Otherwise the new sign-in is reported as expired the instant it's stored."""
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
await db_session.commit()
await store_token(db_session, "brand-new-token", "user@example.com", "global", None)
assert await is_cloud_token_invalid(db_session, None) is False
@pytest.mark.asyncio
async def test_logout_clears_the_flag(self, db_session):
for key, value in [
(CLOUD_TOKEN_KEY, "dead"),
(CLOUD_EMAIL_KEY, "user@example.com"),
(CLOUD_REGION_KEY, "global"),
(CLOUD_TOKEN_INVALID_KEY, "2026-07-14T07:00:00+00:00"),
]:
db_session.add(Settings(key=key, value=value))
await db_session.commit()
await clear_token(db_session, None)
assert await is_cloud_token_invalid(db_session, None) is False
class TestStatusRoute:
"""The endpoint that was lying. ``GET /cloud/status`` drives the "Connected
as ..." bar on the Profiles page and the green dot in Settings."""
async def _store(self, db_session, *, invalid: bool = False):
db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="stored-token"))
db_session.add(Settings(key=CLOUD_EMAIL_KEY, value="user@example.com"))
db_session.add(Settings(key=CLOUD_REGION_KEY, value="global"))
if invalid:
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
await db_session.commit()
@pytest.mark.asyncio
async def test_no_token_is_not_expired(self, async_client, db_session):
body = (await async_client.get("/api/v1/cloud/status")).json()
assert body["is_authenticated"] is False
assert body["sign_in_expired"] is False
@pytest.mark.asyncio
async def test_token_bambu_rejects_reports_expired(self, async_client, db_session, monkeypatch):
"""The whole bug: a stored token Bambu no longer accepts used to come back
as ``is_authenticated: true``, forever."""
await self._store(db_session)
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=False))
body = (await async_client.get("/api/v1/cloud/status")).json()
assert body["is_authenticated"] is False
assert body["sign_in_expired"] is True
assert body["email"] is None
@pytest.mark.asyncio
async def test_token_bambu_accepts_reports_connected(self, async_client, db_session, monkeypatch):
await self._store(db_session)
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=True))
body = (await async_client.get("/api/v1/cloud/status")).json()
assert body["is_authenticated"] is True
assert body["sign_in_expired"] is False
assert body["email"] == "user@example.com"
@pytest.mark.asyncio
async def test_bambu_unreachable_keeps_the_user_signed_in(self, async_client, db_session, monkeypatch):
"""Unknown is not invalid. A Bambu outage must not log the whole install
out of the cloud."""
await self._store(db_session)
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=None))
body = (await async_client.get("/api/v1/cloud/status")).json()
assert body["is_authenticated"] is True
assert body["sign_in_expired"] is False
@pytest.mark.asyncio
async def test_bambu_unreachable_does_not_resurrect_a_known_dead_token(self, async_client, db_session, monkeypatch):
"""...but "unknown" must fall back to what we last knew, not to True."""
await self._store(db_session, invalid=True)
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=None))
body = (await async_client.get("/api/v1/cloud/status")).json()
assert body["is_authenticated"] is False
assert body["sign_in_expired"] is True
@pytest.mark.asyncio
async def test_known_dead_token_does_not_re_ask_bambu(self, async_client, db_session, monkeypatch):
"""Only a new login can revive it, and that clears the flag — so polling
Bambu on every status call would be pure waste."""
await self._store(db_session, invalid=True)
validate = AsyncMock(return_value=False)
monkeypatch.setattr(BambuCloudService, "validate_token", validate)
await async_client.get("/api/v1/cloud/status")
validate.assert_not_awaited()

View file

@ -69,8 +69,30 @@ class TestStatus:
resp = await async_client.get("/api/v1/makerworld/status")
assert resp.status_code == 200
body = resp.json()
# Fresh in-memory DB has no stored token, so can_download must be false
assert body == {"has_cloud_token": False, "can_download": False}
# Fresh in-memory DB has no stored token, so can_download must be false.
# sign_in_expired is False, not True: there is no sign-in to have expired.
assert body == {"has_cloud_token": False, "can_download": False, "sign_in_expired": False}
@pytest.mark.asyncio
async def test_rejected_token_blocks_download_and_reports_expired(self, async_client, db_session):
"""A token Bambu has already rejected downloads nothing. ``can_download``
used to be a bare alias for ``has_cloud_token``, so the import button
stayed live against a dead credential and the user only found out via a
401 toast."""
from backend.app.api.routes.cloud import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
from backend.app.models.settings import Settings
db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="dead-token"))
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
await db_session.commit()
resp = await async_client.get("/api/v1/makerworld/status")
assert resp.status_code == 200
assert resp.json() == {
"has_cloud_token": True,
"can_download": False,
"sign_in_expired": True,
}
class TestResolve:

View file

@ -0,0 +1,245 @@
"""Tests for the S3 presigned-download path in ``services/makerworld.py``.
MakerWorld hands back an AWS presigned URL for the 3MF, and we fetch that one
with ``urllib.request`` rather than httpx httpx re-encodes the query string
and invalidates the S3 signature. That choice silently changed the trust
store: urllib verifies against the OS CA store, httpx against the bundled
``certifi`` bundle. On Windows the two disagree and the download dies with
``CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate`` (#2562).
These tests pin the fix (the opener carries a certifi-backed TLS context) and
the two properties the fix must not break: the no-redirect SSRF guard, and the
URL reaching the transport byte-for-byte.
"""
from __future__ import annotations
import ssl
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import certifi
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from backend.app.services import makerworld as mw
# A presigned URL in the shape Bambu Cloud actually mints: the signature is
# computed over these exact query-string bytes, so any re-encoding breaks it.
S3_URL = (
"https://s3.us-west-2.amazonaws.com/bbl-prod/models/benchy.3mf"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256"
"&X-Amz-Credential=AKIA%2F20260714%2Fus-west-2%2Fs3%2Faws4_request"
"&X-Amz-Date=20260714T070000Z&X-Amz-Expires=300"
"&X-Amz-Signature=abc123&X-Amz-SignedHeaders=host"
)
def _write_test_ca(path) -> str:
"""Write a throwaway self-signed CA to ``path`` and return its CN.
Lets a test assert the opener's TLS context was loaded from *certifi's*
bundle specifically, rather than from the OS store or any other source:
we point ``certifi.where()`` at this file and then check the context
trusts exactly this one cert.
"""
key = ec.generate_private_key(ec.SECP256R1())
common_name = "Bambuddy Test Root CA"
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
now = datetime.now(timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
return common_name
class _FakeResponse:
"""Stand-in for the ``http.client.HTTPResponse`` urllib hands back."""
def __init__(self, body: bytes, status: int = 200):
self.status = status
self._body = body
self._offset = 0
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self, size: int) -> bytes:
chunk = self._body[self._offset : self._offset + size]
self._offset += len(chunk)
return chunk
class _OpenerCapture:
"""Captures the handlers ``build_opener`` was called with, and the Request
the resulting opener was asked to open."""
def __init__(self, response: _FakeResponse | None = None, raises: BaseException | None = None):
self.handlers: tuple = ()
self.request = None
self._response = response or _FakeResponse(b"3MF")
self._raises = raises
def build_opener(self, *handlers):
self.handlers = handlers
opener = MagicMock()
opener.open = self._open
return opener
def _open(self, request, timeout=None):
self.request = request
if self._raises is not None:
raise self._raises
return self._response
def https_handler(self):
for handler in self.handlers:
if isinstance(handler, mw_https_handler_type()):
return handler
return None
def mw_https_handler_type():
from urllib.request import HTTPSHandler
return HTTPSHandler
def _patched_opener(capture: _OpenerCapture):
"""``_download_s3_urllib`` imports ``build_opener`` from ``urllib.request``
at call time, so patching the module attribute is enough."""
return patch("urllib.request.build_opener", side_effect=capture.build_opener)
class TestS3TrustStore:
"""The regression under test: urllib must not fall back to the OS CA store."""
@pytest.mark.asyncio
async def test_opener_gets_an_https_handler(self):
"""Without an explicit HTTPSHandler, urllib builds its own from the OS
trust store which is exactly what fails on Windows (#2562)."""
capture = _OpenerCapture()
with _patched_opener(capture):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
handler = capture.https_handler()
assert handler is not None, "opener was built without an HTTPSHandler — falls back to the OS trust store"
assert isinstance(handler._context, ssl.SSLContext)
@pytest.mark.asyncio
async def test_context_is_loaded_from_certifi(self, tmp_path, monkeypatch):
"""Point certifi at a bundle holding one throwaway root; the opener's
context must trust exactly that root and nothing else. Proves the CAs
come from certifi rather than the system store."""
ca_pem = tmp_path / "test-cacert.pem"
common_name = _write_test_ca(ca_pem)
monkeypatch.setattr(mw.certifi, "where", lambda: str(ca_pem))
capture = _OpenerCapture()
with _patched_opener(capture):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
loaded = capture.https_handler()._context.get_ca_certs()
assert len(loaded) == 1, f"expected only the certifi bundle's cert, got {len(loaded)}"
subject_values = [value for rdn in loaded[0]["subject"] for _, value in rdn]
assert common_name in subject_values
@pytest.mark.asyncio
async def test_context_verifies_and_checks_hostname(self):
"""certifi swaps the CA source, not the verification policy — a context
with verification off would 'fix' #2562 by disabling TLS security."""
capture = _OpenerCapture()
with _patched_opener(capture):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
context = capture.https_handler()._context
assert context.verify_mode == ssl.CERT_REQUIRED
assert context.check_hostname is True
def test_real_context_trusts_the_certifi_bundle(self):
"""Sanity-check the un-mocked helper against the shipped bundle: it must
load a real-world number of roots, not an empty set."""
context = mw._s3_ssl_context()
assert len(context.get_ca_certs()) == len(ssl.create_default_context(cafile=certifi.where()).get_ca_certs())
assert len(context.get_ca_certs()) > 50
class TestS3DownloadUnchanged:
"""Properties the TLS fix must not regress."""
@pytest.mark.asyncio
async def test_redirects_are_still_refused(self):
"""The host allowlist is only enforced on the initial URL, so following
a 302 off S3 would bypass it. The no-redirect handler must survive."""
capture = _OpenerCapture()
with _patched_opener(capture):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
from urllib.request import HTTPRedirectHandler
# build_opener takes handler classes *or* instances; the redirect
# blocker is passed as a class, so normalise before probing it.
blockers = []
for handler in capture.handlers:
instance = handler() if isinstance(handler, type) else handler
if isinstance(instance, HTTPRedirectHandler):
if instance.redirect_request(None, None, None, None, None) is None:
blockers.append(instance)
assert blockers, "no redirect-blocking handler passed to build_opener"
@pytest.mark.asyncio
async def test_url_reaches_the_transport_verbatim(self):
"""The whole reason this path uses urllib: S3 signs the exact
query-string bytes. Any normalisation yields SignatureDoesNotMatch."""
capture = _OpenerCapture()
with _patched_opener(capture):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
assert capture.request.full_url == S3_URL
@pytest.mark.asyncio
async def test_returns_body_and_filename(self):
capture = _OpenerCapture(response=_FakeResponse(b"PK\x03\x04payload"))
with _patched_opener(capture):
data, filename = await mw._download_s3_urllib(S3_URL, "benchy.3mf")
assert data == b"PK\x03\x04payload"
assert filename == "benchy.3mf"
@pytest.mark.asyncio
async def test_non_200_raises_unavailable(self):
capture = _OpenerCapture(response=_FakeResponse(b"", status=403))
with _patched_opener(capture), pytest.raises(mw.MakerWorldUnavailableError, match="HTTP 403"):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
@pytest.mark.asyncio
async def test_size_cap_enforced(self, monkeypatch):
monkeypatch.setattr(mw, "_MAX_3MF_BYTES", 1024)
capture = _OpenerCapture(response=_FakeResponse(b"x" * 4096))
with _patched_opener(capture), pytest.raises(mw.MakerWorldUnavailableError, match="exceeds"):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")
@pytest.mark.asyncio
async def test_tls_failure_still_surfaces_as_s3_download_failed(self):
"""If verification fails for a genuine reason (expired cert, MITM proxy),
the user must still get the actionable wrapped error the fix removes
the spurious failures, it doesn't swallow the real ones."""
verify_error = ssl.SSLCertVerificationError("certificate verify failed: unable to get local issuer certificate")
capture = _OpenerCapture(raises=verify_error)
with _patched_opener(capture), pytest.raises(mw.MakerWorldUnavailableError, match="S3 download failed"):
await mw._download_s3_urllib(S3_URL, "benchy.3mf")

View file

@ -1305,6 +1305,8 @@ export interface CloudAuthStatus {
is_authenticated: boolean;
email: string | null;
region?: 'global' | 'china' | null;
/** A token is stored but Bambu no longer accepts it — tell the user why the login form is back. */
sign_in_expired?: boolean;
}
export interface CloudLoginResponse {
@ -1366,6 +1368,8 @@ export interface OrcaProfileDetail {
export interface MakerworldStatus {
has_cloud_token: boolean;
can_download: boolean;
/** A token is stored but Bambu rejected it — downloads will fail until the user signs in again. */
sign_in_expired?: boolean;
}
export interface MakerworldResolvedModel {

View file

@ -3340,6 +3340,8 @@ export default {
},
},
connectedAs: 'Verbunden als',
signInExpiredTitle: 'Bambu-Cloud-Anmeldung abgelaufen',
signInExpiredBody: 'Bambu Lab akzeptiert das gespeicherte Token nicht mehr. Melden Sie sich erneut an, um Cloud-Profile, MakerWorld-Importe und Firmware-Prüfungen wiederherzustellen.',
logout: 'Abmelden',
noLogoutPermission: 'Sie haben keine Berechtigung zum Abmelden',
failedToLoad: 'Profile konnten nicht geladen werden',
@ -6499,6 +6501,8 @@ export default {
resolveButton: 'Laden',
signInRequiredTitle: 'Bambu-Cloud-Anmeldung für Download erforderlich',
signInRequiredBody: 'Modell-Details können anonym angezeigt werden, aber MakerWorld verlangt eine Bambu-Cloud-Anmeldung zum Herunterladen der 3MF-Dateien.',
signInExpiredTitle: 'Bambu-Cloud-Anmeldung abgelaufen',
signInExpiredBody: 'Sie sind weiterhin bei Bambuddy angemeldet, aber Bambu Lab akzeptiert das gespeicherte Token nicht mehr, daher schlagen Downloads fehl. Melden Sie sich erneut bei Bambu Cloud an.',
openCloudSettings: 'Cloud-Einstellungen öffnen',
untitledModel: 'Unbenanntes Modell',
byCreator: 'von {{name}}',

View file

@ -3369,6 +3369,8 @@ export default {
},
},
connectedAs: 'Connected as',
signInExpiredTitle: 'Bambu Cloud sign-in expired',
signInExpiredBody: 'Bambu Lab no longer accepts the stored token. Sign in again to restore cloud profiles, MakerWorld imports and firmware checks.',
logout: 'Logout',
noLogoutPermission: 'You do not have permission to logout',
failedToLoad: 'Failed to load profiles',
@ -6543,6 +6545,8 @@ export default {
resolveButton: 'Resolve',
signInRequiredTitle: 'Bambu Cloud sign-in required to download',
signInRequiredBody: 'You can browse model details anonymously, but MakerWorld requires a Bambu Cloud account to download 3MF files.',
signInExpiredTitle: 'Bambu Cloud sign-in expired',
signInExpiredBody: 'You are still signed in to Bambuddy, but Bambu Lab has stopped accepting the stored token, so downloads will fail. Sign in to Bambu Cloud again.',
openCloudSettings: 'Open Cloud settings',
untitledModel: 'Untitled model',
byCreator: 'by {{name}}',

View file

@ -3343,6 +3343,8 @@ export default {
},
},
connectedAs: 'Conectado como',
signInExpiredTitle: 'La sesión de Bambu Cloud ha caducado',
signInExpiredBody: 'Bambu Lab ya no acepta el token almacenado. Inicie sesión de nuevo para restaurar los perfiles en la nube, las importaciones de MakerWorld y las comprobaciones de firmware.',
logout: 'Cerrar sesión',
noLogoutPermission: 'No tiene permiso para cerrar sesión',
failedToLoad: 'Error al cargar los perfiles',
@ -6508,6 +6510,8 @@ export default {
resolveButton: 'Resolver',
signInRequiredTitle: 'Se requiere iniciar sesión en Bambu Cloud para descargar',
signInRequiredBody: 'Puede explorar los detalles del modelo de forma anónima, pero MakerWorld requiere una cuenta de Bambu Cloud para descargar archivos 3MF.',
signInExpiredTitle: 'La sesión de Bambu Cloud ha caducado',
signInExpiredBody: 'Sigue conectado a Bambuddy, pero Bambu Lab ha dejado de aceptar el token almacenado, por lo que las descargas fallarán. Inicie sesión de nuevo en Bambu Cloud.',
openCloudSettings: 'Abrir los ajustes de la nube',
untitledModel: 'Modelo sin título',
byCreator: 'de {{name}}',

View file

@ -3329,6 +3329,8 @@ export default {
},
},
connectedAs: 'Connecté en tant que',
signInExpiredTitle: 'Session Bambu Cloud expirée',
signInExpiredBody: 'Bambu Lab n\'accepte plus le jeton enregistré. Reconnectez-vous pour rétablir les profils cloud, les imports MakerWorld et la vérification des firmwares.',
logout: 'Déconnexion',
noLogoutPermission: 'Pas d\'autorisation de déconnexion',
failedToLoad: 'Échec chargement profils',
@ -6488,6 +6490,8 @@ export default {
resolveButton: 'Résoudre',
signInRequiredTitle: 'Connexion Bambu Cloud requise pour télécharger',
signInRequiredBody: 'Vous pouvez consulter les détails du modèle anonymement, mais MakerWorld nécessite un compte Bambu Cloud pour télécharger les fichiers 3MF.',
signInExpiredTitle: 'Session Bambu Cloud expirée',
signInExpiredBody: 'Vous êtes toujours connecté à Bambuddy, mais Bambu Lab n\'accepte plus le jeton enregistré : les téléchargements échoueront. Reconnectez-vous à Bambu Cloud.',
openCloudSettings: 'Ouvrir les paramètres Cloud',
untitledModel: 'Modèle sans titre',
byCreator: 'par {{name}}',

View file

@ -3328,6 +3328,8 @@ export default {
},
},
connectedAs: 'Connesso come',
signInExpiredTitle: 'Accesso a Bambu Cloud scaduto',
signInExpiredBody: 'Bambu Lab non accetta più il token salvato. Accedi di nuovo per ripristinare i profili cloud, le importazioni da MakerWorld e i controlli del firmware.',
logout: 'Esci',
noLogoutPermission: 'Non hai il permesso di disconnetterti',
failedToLoad: 'Caricamento profili fallito',
@ -6487,6 +6489,8 @@ export default {
resolveButton: 'Risolvi',
signInRequiredTitle: 'Accesso Bambu Cloud richiesto per scaricare',
signInRequiredBody: 'Puoi consultare i dettagli del modello in modo anonimo, ma MakerWorld richiede un account Bambu Cloud per scaricare i file 3MF.',
signInExpiredTitle: 'Accesso a Bambu Cloud scaduto',
signInExpiredBody: 'Hai ancora effettuato l\'accesso a Bambuddy, ma Bambu Lab non accetta più il token salvato, quindi i download non riusciranno. Accedi di nuovo a Bambu Cloud.',
openCloudSettings: 'Apri impostazioni Cloud',
untitledModel: 'Modello senza titolo',
byCreator: 'di {{name}}',

View file

@ -3340,6 +3340,8 @@ export default {
},
},
connectedAs: '接続中:',
signInExpiredTitle: 'Bambu Cloud のサインインの有効期限が切れました',
signInExpiredBody: '保存されたトークンは Bambu Lab に受け付けられなくなりました。クラウドプロファイル、MakerWorld のインポート、ファームウェア確認を復元するには、再度サインインしてください。',
logout: 'ログアウト',
noLogoutPermission: 'ログアウトする権限がありません',
failedToLoad: 'ファイルの読み込みに失敗しました',
@ -6499,6 +6501,8 @@ export default {
resolveButton: '読み込む',
signInRequiredTitle: 'ダウンロードには Bambu Cloud へのサインインが必要です',
signInRequiredBody: 'モデルの詳細は匿名で閲覧できますが、3MF ファイルをダウンロードするには Bambu Cloud アカウントが必要です。',
signInExpiredTitle: 'Bambu Cloud のサインインの有効期限が切れました',
signInExpiredBody: 'Bambuddy にはサインインしたままですが、Bambu Lab が保存されたトークンを受け付けなくなったため、ダウンロードは失敗します。Bambu Cloud に再度サインインしてください。',
openCloudSettings: 'Cloud 設定を開く',
untitledModel: '無題のモデル',
byCreator: '作成者: {{name}}',

View file

@ -3164,6 +3164,8 @@ export default {
noSearchResults: '검색어와 일치하는 프리셋이 없습니다'
},
connectedAs: '연결된 계정',
signInExpiredTitle: 'Bambu 클라우드 로그인이 만료되었습니다',
signInExpiredBody: 'Bambu Lab이 저장된 토큰을 더 이상 허용하지 않습니다. 클라우드 프로필, MakerWorld 가져오기, 펌웨어 확인을 복구하려면 다시 로그인하세요.',
logout: '로그아웃',
noLogoutPermission: '로그아웃 권한이 없습니다',
failedToLoad: '프로필 불러오기 실패',
@ -5968,6 +5970,8 @@ export default {
resolveButton: '확인',
signInRequiredTitle: '다운로드하려면 Bambu 클라우드 로그인 필요',
signInRequiredBody: '익명으로 모델 세부 정보를 탐색할 수 있지만 MakerWorld는 3MF 파일을 다운로드하려면 Bambu 클라우드 계정이 필요합니다.',
signInExpiredTitle: 'Bambu 클라우드 로그인이 만료되었습니다',
signInExpiredBody: 'Bambuddy에는 여전히 로그인되어 있지만 Bambu Lab이 저장된 토큰을 더 이상 허용하지 않아 다운로드가 실패합니다. Bambu 클라우드에 다시 로그인하세요.',
openCloudSettings: '클라우드 설정 열기',
untitledModel: '제목 없는 모델',
byCreator: '{{name}} 제작',

View file

@ -3328,6 +3328,8 @@ export default {
},
},
connectedAs: 'Conectado como',
signInExpiredTitle: 'Sessão do Bambu Cloud expirada',
signInExpiredBody: 'A Bambu Lab não aceita mais o token armazenado. Entre novamente para restaurar os perfis na nuvem, as importações do MakerWorld e a verificação de firmware.',
logout: 'Sair',
noLogoutPermission: 'Você não tem permissão para sair',
failedToLoad: 'Falha ao carregar perfis',
@ -6487,6 +6489,8 @@ export default {
resolveButton: 'Resolver',
signInRequiredTitle: 'Login no Bambu Cloud necessário para baixar',
signInRequiredBody: 'Você pode navegar pelos detalhes do modelo anonimamente, mas o MakerWorld exige uma conta Bambu Cloud para baixar arquivos 3MF.',
signInExpiredTitle: 'Sessão do Bambu Cloud expirada',
signInExpiredBody: 'Você continua conectado ao Bambuddy, mas a Bambu Lab deixou de aceitar o token armazenado, então os downloads vão falhar. Entre novamente no Bambu Cloud.',
openCloudSettings: 'Abrir configurações do Cloud',
untitledModel: 'Modelo sem título',
byCreator: 'por {{name}}',

View file

@ -3344,6 +3344,8 @@ export default {
},
},
connectedAs: 'Bağlı kullanıcı',
signInExpiredTitle: 'Bambu Cloud oturumu sona erdi',
signInExpiredBody: 'Bambu Lab kayıtlı belirteci artık kabul etmiyor. Bulut profillerini, MakerWorld içe aktarmalarını ve donanım yazılımı denetimlerini geri yüklemek için yeniden oturum açın.',
logout: ıkış',
noLogoutPermission: ıkış yapma izniniz yok',
failedToLoad: 'Profiller yüklenemedi',
@ -6439,6 +6441,8 @@ export default {
resolveButton: 'Çöz',
signInRequiredTitle: 'İndirme için Bambu Cloud girişi gerekli',
signInRequiredBody: 'Model ayrıntılarına anonim olarak göz atabilirsiniz, ancak MakerWorld 3MF dosyalarını indirmek için bir Bambu Cloud hesabı gerektirir.',
signInExpiredTitle: 'Bambu Cloud oturumu sona erdi',
signInExpiredBody: 'Bambuddy oturumunuz açık kalmaya devam ediyor, ancak Bambu Lab kayıtlı belirteci artık kabul etmediği için indirmeler başarısız olacak. Bambu Cloud oturumunu yeniden açın.',
openCloudSettings: 'Bulut ayarlarını aç',
untitledModel: 'Adsız model',
byCreator: '{{name}} tarafından',

View file

@ -3328,6 +3328,8 @@ export default {
},
},
connectedAs: '已连接为',
signInExpiredTitle: 'Bambu Cloud 登录已过期',
signInExpiredBody: 'Bambu Lab 不再接受已保存的令牌。请重新登录以恢复云配置文件、MakerWorld 导入和固件检查。',
logout: '退出登录',
noLogoutPermission: '您没有退出登录的权限',
failedToLoad: '加载配置文件失败',
@ -6486,6 +6488,8 @@ export default {
resolveButton: '解析',
signInRequiredTitle: '下载需要登录 Bambu Cloud',
signInRequiredBody: '您可以匿名浏览模型详情,但下载 3MF 文件需要 Bambu Cloud 账户。',
signInExpiredTitle: 'Bambu Cloud 登录已过期',
signInExpiredBody: '您仍处于 Bambuddy 的登录状态,但 Bambu Lab 已不再接受已保存的令牌,因此下载会失败。请重新登录 Bambu Cloud。',
openCloudSettings: '打开云设置',
untitledModel: '无标题模型',
byCreator: '作者: {{name}}',

View file

@ -3328,6 +3328,8 @@ export default {
},
},
connectedAs: '已連線為',
signInExpiredTitle: 'Bambu Cloud 登入已過期',
signInExpiredBody: 'Bambu Lab 不再接受已儲存的權杖。請重新登入以恢復雲端設定檔、MakerWorld 匯入與韌體檢查。',
logout: '登出',
noLogoutPermission: '您沒有登出的權限',
failedToLoad: '載入設定檔案失敗',
@ -6486,6 +6488,8 @@ export default {
resolveButton: '解析',
signInRequiredTitle: '下載需要登入 Bambu Cloud',
signInRequiredBody: '您可以匿名瀏覽模型詳情,但下載 3MF 檔案需要 Bambu Cloud 帳戶。',
signInExpiredTitle: 'Bambu Cloud 登入已過期',
signInExpiredBody: '您仍處於 Bambuddy 的登入狀態,但 Bambu Lab 已不再接受已儲存的權杖,因此下載會失敗。請重新登入 Bambu Cloud。',
openCloudSettings: '開啟雲端設定',
untitledModel: '無標題模型',
byCreator: '作者: {{name}}',

View file

@ -402,10 +402,13 @@ export function MakerworldPage() {
const instances = resolved?.instances ?? [];
const alreadyImported = (resolved?.already_imported_library_ids.length ?? 0) > 0;
const hasToken = statusQuery.data?.has_cloud_token ?? false;
// Only block Print Now / Import actions on an import-capable login.
// Browse/resolve works anonymously.
const canDownload = statusQuery.data?.can_download ?? false;
// A stored token Bambu has rejected downloads nothing, but it isn't "no
// token" either — saying "sign in" to someone who believes they already are
// is what made this so confusing. Name the actual state.
const signInExpired = statusQuery.data?.sign_in_expired ?? false;
const coverUrl = useMemo(() => pickString(design, 'coverUrl'), [design]);
const title = pickString(design, 'title');
@ -430,17 +433,21 @@ export function MakerworldPage() {
screens (tablet/phone), with the sidebar tucked below the main flow. */}
<div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
<div className="space-y-6 min-w-0">
{!hasToken && (
{!canDownload && (
<Card className="border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20">
<CardContent>
<div className="flex items-start gap-3 py-2">
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium text-amber-900 dark:text-amber-100">
{t('makerworld.signInRequiredTitle')}
{signInExpired
? t('makerworld.signInExpiredTitle')
: t('makerworld.signInRequiredTitle')}
</p>
<p className="text-amber-800 dark:text-amber-200 mt-1">
{t('makerworld.signInRequiredBody')}{' '}
{signInExpired
? t('makerworld.signInExpiredBody')
: t('makerworld.signInRequiredBody')}{' '}
<Link to="/profiles" className="underline">
{t('makerworld.openCloudSettings')}
</Link>

View file

@ -2959,6 +2959,20 @@ export function ProfilesPage() {
</div>
)}
{/* A stored token Bambu has stopped accepting logs the user out of the
cloud without them doing anything, so the login form reappearing
needs an explanation otherwise it reads as Bambuddy losing the
session for no reason. */}
{status?.sign_in_expired && (
<div className="flex items-start gap-3 p-3 mb-6 rounded-lg border border-yellow-500/40 bg-yellow-500/10">
<AlertTriangle className="w-5 h-5 shrink-0 text-yellow-400 mt-0.5" />
<div className="text-sm">
<p className="text-white font-medium">{t('profiles.signInExpiredTitle')}</p>
<p className="text-bambu-gray">{t('profiles.signInExpiredBody')}</p>
</div>
</div>
)}
{!status?.is_authenticated ? (
<LoginForm onSuccess={handleLoginSuccess} t={t} />
) : settingsLoading ? (

View file

@ -113,6 +113,13 @@ idna>=3.15
# HTTP client (used for OIDC token exchange)
httpx>=0.26.0
# CA bundle. Already a transitive dep of httpx, but services/makerworld.py
# imports it directly to pin the S3 presigned download's urllib opener to the
# same trust store httpx uses — the Windows OS store lacks the Amazon root
# until CryptoAPI lazily caches it (#2562). Declared explicitly so a future
# httpx release that drops certifi can't silently break that import.
certifi>=2024.2.2
# HTTP client with browser TLS-fingerprint impersonation. Used only for
# the bambulab.com firmware-download page in services/firmware_check.py:
# Bambu's Cloudflare WAF gates the page behind a JA3/TLS-fingerprint

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CevSiltg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7t2liT66.css">
<script type="module" crossorigin src="/assets/index-B_yurhVI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4NXlsp1C.css">
</head>
<body>
<div id="root"></div>