فهرست منبع

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.
maziggy 1 ماه پیش
والد
کامیت
09b739b95d
34فایلهای تغییر یافته به همراه1129 افزوده شده و 62 حذف شده
  1. 0 0
      CHANGELOG.md
  2. 133 22
      backend/app/api/routes/cloud.py
  3. 24 4
      backend/app/api/routes/makerworld.py
  4. 10 0
      backend/app/core/database.py
  5. 8 0
      backend/app/models/user.py
  6. 4 0
      backend/app/schemas/cloud.py
  7. 4 0
      backend/app/schemas/makerworld.py
  8. 159 11
      backend/app/services/bambu_cloud.py
  9. 65 8
      backend/app/services/makerworld.py
  10. 13 2
      backend/tests/integration/test_cloud_auth.py
  11. 3 3
      backend/tests/integration/test_makerworld_apikey_auth.py
  12. 37 3
      backend/tests/unit/services/test_makerworld.py
  13. 317 0
      backend/tests/unit/test_cloud_token_expiry.py
  14. 24 2
      backend/tests/unit/test_makerworld_routes.py
  15. 245 0
      backend/tests/unit/test_makerworld_s3_tls.py
  16. 4 0
      frontend/src/api/client.ts
  17. 4 0
      frontend/src/i18n/locales/de.ts
  18. 4 0
      frontend/src/i18n/locales/en.ts
  19. 4 0
      frontend/src/i18n/locales/es.ts
  20. 4 0
      frontend/src/i18n/locales/fr.ts
  21. 4 0
      frontend/src/i18n/locales/it.ts
  22. 4 0
      frontend/src/i18n/locales/ja.ts
  23. 4 0
      frontend/src/i18n/locales/ko.ts
  24. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 4 0
      frontend/src/i18n/locales/tr.ts
  26. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  27. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  28. 11 4
      frontend/src/pages/MakerworldPage.tsx
  29. 14 0
      frontend/src/pages/ProfilesPage.tsx
  30. 7 0
      requirements.txt
  31. 1 0
      static/assets/index-4NXlsp1C.css
  32. 0 1
      static/assets/index-7t2liT66.css
  33. 0 0
      static/assets/index-B_yurhVI.js
  34. 2 2
      static/index.html

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 133 - 22
backend/app/api/routes/cloud.py

@@ -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.
+
+    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.
     """
-    if user is not None:
-        from sqlalchemy import update
+    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(

+ 24 - 4
backend/app/api/routes/makerworld.py

@@ -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)

+ 10 - 0
backend/app/core/database.py

@@ -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)

+ 8 - 0
backend/app/models/user.py

@@ -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,

+ 4 - 0
backend/app/schemas/cloud.py

@@ -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):

+ 4 - 0
backend/app/schemas/makerworld.py

@@ -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.",
+    )

+ 159 - 11
backend/app/services/bambu_cloud.py

@@ -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

+ 65 - 8
backend/app/services/makerworld.py

@@ -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}")

+ 13 - 2
backend/tests/integration/test_cloud_auth.py

@@ -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)
 

+ 3 - 3
backend/tests/integration/test_makerworld_apikey_auth.py

@@ -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}

+ 37 - 3
backend/tests/unit/services/test_makerworld.py

@@ -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):

+ 317 - 0
backend/tests/unit/test_cloud_token_expiry.py

@@ -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()

+ 24 - 2
backend/tests/unit/test_makerworld_routes.py

@@ -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:

+ 245 - 0
backend/tests/unit/test_makerworld_s3_tls.py

@@ -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")

+ 4 - 0
frontend/src/api/client.ts

@@ -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 {

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/es.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/ko.ts

@@ -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}} 제작',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/tr.ts

@@ -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',

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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}}',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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}}',

+ 11 - 4
frontend/src/pages/MakerworldPage.tsx

@@ -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>

+ 14 - 0
frontend/src/pages/ProfilesPage.tsx

@@ -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 ? (

+ 7 - 0
requirements.txt

@@ -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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/assets/index-4NXlsp1C.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/index-7t2liT66.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-B_yurhVI.js


+ 2 - 2
static/index.html

@@ -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>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است