فهرست منبع

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 json
 import logging
 import logging
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 from typing import Literal
 from typing import Literal
 
 
 from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
 from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
 from fastapi.security import HTTPAuthorizationCredentials
 from fastapi.security import HTTPAuthorizationCredentials
-from sqlalchemy import select
+from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
@@ -21,7 +22,7 @@ from backend.app.core.auth import (
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
     security,
     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.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -46,6 +47,7 @@ from backend.app.services.bambu_cloud import (
     BambuCloudAuthError,
     BambuCloudAuthError,
     BambuCloudError,
     BambuCloudError,
     BambuCloudService,
     BambuCloudService,
+    invalidate_validation_cache,
 )
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 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_TOKEN_KEY = "bambu_cloud_token"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
 CLOUD_REGION_KEY = "bambu_cloud_region"
 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:
 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"
     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]:
 async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
     """Get stored cloud token, email, and region.
     """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 a user is provided (auth enabled), stores on the user record.
     When user is None (auth disabled), stores in global Settings table.
     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)
     region = _normalise_region(region)
+    invalidate_validation_cache(token)
     if user is not None:
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
         # so use a direct UPDATE via the route's db session.
-        from sqlalchemy import update
-
         await db.execute(
         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()
         await db.commit()
         return
         return
@@ -223,6 +289,7 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
             setting.value = value
             setting.value = value
         else:
         else:
             db.add(Settings(key=key, value=value))
             db.add(Settings(key=key, value=value))
+    await _clear_cloud_token_invalid(db, None)
     await db.commit()
     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 a user is provided (auth enabled), clears that user's credentials.
     When user is None (auth disabled), clears from global Settings table.
     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(
         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()
         await db.commit()
         return
         return
 
 
     # Fallback: global storage (auth disabled)
     # Fallback: global storage (auth disabled)
     result = await db.execute(
     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():
     for setting in result.scalars().all():
         await db.delete(setting)
         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
     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()``.
     (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)
     token, _email, region = await get_stored_token(db, user)
     if not token:
     if not token:
         return None
         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)
     cloud.set_token(token)
     return cloud
     return cloud
 
 
@@ -363,27 +446,55 @@ async def get_auth_status(
 ):
 ):
     """Get current cloud authentication 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)
     token, email, region = await get_stored_token(db, current_user)
     if not token:
     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)
     cloud.set_token(token)
     try:
     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:
     finally:
         await cloud.close()
         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)
 @router.post("/login", response_model=CloudLoginResponse)
 async def login(
 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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.api.routes.library import save_3mf_bytes_to_library
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 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.
     stored Bambu Cloud bearer token when available.
 
 
     Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
     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)
     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:
 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
     cloud_token_user = current_user or api_key_cloud_owner
     token, _email, _region = await get_stored_token(db, cloud_token_user)
     token, _email, _region = await get_stored_token(db, cloud_token_user)
     has_token = bool(token)
     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)
 @router.post("/resolve", response_model=MakerWorldResolvedModel)

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

@@ -3246,6 +3246,16 @@ async def run_migrations(conn):
     else:
     else:
         await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
         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
     # Data migration: drop the embedded 3MF Title (`print_name`) from library
     # file metadata so the FileManager displays the filename, not the title (#1489).
     # file metadata so the FileManager displays the filename, not the title (#1489).
     await _migrate_drop_library_print_name(conn)
     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)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
     # "global" or "china"; NULL treated as "global" for legacy rows.
     # "global" or "china"; NULL treated as "global" for legacy rows.
     cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
     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
     # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
     # with short-lived access tokens (1h) and rotating single-use refresh tokens,
     # 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
     is_authenticated: bool
     email: str | None = None
     email: str | None = None
     region: Region | 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):
 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")
     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.")
     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.
 Handles authentication and profile management with Bambu Lab's cloud services.
 """
 """
 
 
+import hashlib
 import logging
 import logging
-from datetime import datetime, timedelta, timezone
+import time
+from collections.abc import Awaitable, Callable
+from datetime import datetime, timezone
 
 
 import httpx
 import httpx
 
 
@@ -14,6 +17,34 @@ logger = logging.getLogger(__name__)
 BAMBU_API_BASE = "https://api.bambulab.com"
 BAMBU_API_BASE = "https://api.bambulab.com"
 BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 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
 # 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
 # Bambuddy — the URL in parens makes the source unambiguous so Bambu can
 # distinguish our traffic from impersonators. This is the opposite of what the
 # 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:
 class BambuCloudService:
     """Service for interacting with Bambu Lab Cloud API."""
     """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.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
         self.access_token: str | None = None
         self.access_token: str | None = None
         self.refresh_token: str | None = None
         self.refresh_token: str | None = None
         self.token_expiry: datetime | 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
         # Prefer an explicitly-injected client (tests), else fall back to the
         # app-scoped shared client (production), and finally create our own so
         # app-scoped shared client (production), and finally create our own so
         # scripts / tests that skip the lifespan still get a working service.
         # scripts / tests that skip the lifespan still get a working service.
@@ -136,11 +179,94 @@ class BambuCloudService:
 
 
     @property
     @property
     def is_authenticated(self) -> bool:
     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:
         if not self.access_token:
             return False
             return False
         return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
         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:
     def _get_headers(self) -> dict:
         """Get headers for authenticated requests."""
         """Get headers for authenticated requests."""
         headers = {
         headers = {
@@ -313,9 +439,10 @@ class BambuCloudService:
             if response.status_code == 200 and access_token:
             if response.status_code == 200 and access_token:
                 self.access_token = access_token
                 self.access_token = access_token
                 self.refresh_token = data.get("refreshToken")
                 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"}
                 return {"success": True, "message": "Login successful"}
 
 
             # Provide helpful error message
             # Provide helpful error message
@@ -333,16 +460,30 @@ class BambuCloudService:
             return {"success": False, "message": f"TOTP verification error: {e}"}
             return {"success": False, "message": f"TOTP verification error: {e}"}
 
 
     def _set_tokens(self, data: dict):
     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.access_token = data.get("accessToken")
         self.refresh_token = data.get("refreshToken")
         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):
     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.access_token = access_token
-        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
+        self.token_expiry = None
 
 
     def logout(self):
     def logout(self):
         """Clear authentication state."""
         """Clear authentication state."""
@@ -391,6 +532,7 @@ class BambuCloudService:
 
 
             data = response.json()
             data = response.json()
 
 
+            await self._note_response(response)
             if response.status_code == 200:
             if response.status_code == 200:
                 return data
                 return data
 
 
@@ -411,6 +553,7 @@ class BambuCloudService:
                 params={"version": _SLICER_API_VERSION},
                 params={"version": _SLICER_API_VERSION},
             )
             )
 
 
+            await self._note_response(response)
             if response.status_code == 200:
             if response.status_code == 200:
                 return response.json()
                 return response.json()
 
 
@@ -464,6 +607,7 @@ class BambuCloudService:
 
 
             data = response.json()
             data = response.json()
 
 
+            await self._note_response(response)
             if response.status_code in (200, 201):
             if response.status_code in (200, 201):
                 return data
                 return data
 
 
@@ -549,6 +693,7 @@ class BambuCloudService:
 
 
             data = response.json()
             data = response.json()
 
 
+            await self._note_response(response)
             if response.status_code == 200:
             if response.status_code == 200:
                 return data
                 return data
 
 
@@ -578,6 +723,7 @@ class BambuCloudService:
                 params={"version": _SLICER_API_VERSION},
                 params={"version": _SLICER_API_VERSION},
             )
             )
 
 
+            await self._note_response(response)
             if response.status_code in (200, 204):
             if response.status_code in (200, 204):
                 return {"success": True, "message": "Setting deleted"}
                 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()
                 f"{self.base_url}/v1/iot-service/api/user/bind", headers=self._get_headers()
             )
             )
 
 
+            await self._note_response(response)
             if response.status_code == 200:
             if response.status_code == 200:
                 return response.json()
                 return response.json()
 
 
@@ -626,6 +773,7 @@ class BambuCloudService:
                 params={"device_id": device_id},
                 params={"device_id": device_id},
             )
             )
 
 
+            await self._note_response(response)
             if response.status_code == 200:
             if response.status_code == 200:
                 data = response.json()
                 data = response.json()
                 # API wraps response in 'data' field
                 # API wraps response in 'data' field

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

@@ -20,9 +20,12 @@ from __future__ import annotations
 import asyncio
 import asyncio
 import logging
 import logging
 import re
 import re
+import ssl
+from collections.abc import Awaitable, Callable
 from typing import Any
 from typing import Any
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
+import certifi
 import httpx
 import httpx
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -58,6 +61,18 @@ _CLIENT_HEADERS = {
     "Referer": "https://makerworld.com/",
     "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+)")
 _MODEL_ID_RE = re.compile(r"/models/(\d+)")
 _PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
 _PROFILE_ID_RE = re.compile(r"#profileId[-=](\d+)")
 _MAX_3MF_BYTES = 200 * 1024 * 1024  # 200 MB hard cap
 _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
 _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:
 def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     """Register an app-scoped ``httpx.AsyncClient`` for service reuse.
     """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
     Runs the blocking urllib call in a thread executor so we don't stall
     the event loop.
     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
     # 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
     # 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]
         def redirect_request(self, *args, **kwargs):  # type: ignore[override]
             return None
             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:
     def _blocking_fetch() -> bytes:
         req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
         req = Request(url, headers={"User-Agent": _CLIENT_HEADERS["User-Agent"]})
@@ -195,7 +233,13 @@ class MakerWorldService:
         self,
         self,
         client: httpx.AsyncClient | None = None,
         client: httpx.AsyncClient | None = None,
         auth_token: str | 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:
         if client is not None:
             self._client = client
             self._client = client
             self._owns_client = False
             self._owns_client = False
@@ -211,6 +255,16 @@ class MakerWorldService:
         if self._owns_client:
         if self._owns_client:
             await self._client.aclose()
             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]:
     def _headers(self) -> dict[str, str]:
         headers = dict(_CLIENT_HEADERS)
         headers = dict(_CLIENT_HEADERS)
         if self._auth_token:
         if self._auth_token:
@@ -249,8 +303,13 @@ class MakerWorldService:
         # because the UI remedy is completely different: 401 → re-login,
         # because the UI remedy is completely different: 401 → re-login,
         # 403 → user has to go to MakerWorld and meet the access requirement.
         # 403 → user has to go to MakerWorld and meet the access requirement.
         if response.status_code == 401:
         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:
         if response.status_code == 403:
             upstream = _extract_upstream_error(response)
             upstream = _extract_upstream_error(response)
             raise MakerWorldForbiddenError(
             raise MakerWorldForbiddenError(
@@ -410,10 +469,8 @@ class MakerWorldService:
             raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
             raise MakerWorldUnavailableError(f"Bambu Lab API request failed: {exc}") from exc
 
 
         if response.status_code == 401:
         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:
         if response.status_code == 403:
             upstream = _extract_upstream_error(response)
             upstream = _extract_upstream_error(response)
             raise MakerWorldForbiddenError(upstream or f"Bambu Lab refused access to profile {profile_id}")
             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
     @pytest.mark.integration
     async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
     async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
         """GET /cloud/status returns the stored region so the UI can render
         """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.api.routes.cloud import store_token
         from backend.app.core.database import async_session
         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:
             async with async_session() as db:
                 await store_token(db, "cn-token", "token-auth", "china", user=None)
                 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},
             headers={"X-API-Key": key},
         )
         )
         assert resp.status_code == 200, resp.text
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -143,7 +143,7 @@ class TestStatusEndpoint:
             headers={"X-API-Key": key},
             headers={"X-API-Key": key},
         )
         )
         assert resp.status_code == 200
         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:
 class TestResolveEndpoint:
@@ -299,4 +299,4 @@ class TestJwtPathUnchanged:
             headers={"Authorization": f"Bearer {admin_token}"},
             headers={"Authorization": f"Bearer {admin_token}"},
         )
         )
         assert resp.status_code == 200
         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)
             await service.get_design(404)
 
 
     @pytest.mark.asyncio
     @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 = MagicMock()
         resp.status_code = 401
         resp.status_code = 401
         resp.json.return_value = {"code": 1, "error": "Please log in"}
         resp.json.return_value = {"code": 1, "error": "Please log in"}
@@ -162,8 +165,39 @@ class TestGetDesign:
 
 
         with pytest.raises(MakerWorldAuthError) as exc_info:
         with pytest.raises(MakerWorldAuthError) as exc_info:
             await service.get_design(1)
             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
     @pytest.mark.asyncio
     async def test_maps_403_to_forbidden_with_upstream_reason(self, service):
     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")
         resp = await async_client.get("/api/v1/makerworld/status")
         assert resp.status_code == 200
         assert resp.status_code == 200
         body = resp.json()
         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:
 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;
   is_authenticated: boolean;
   email: string | null;
   email: string | null;
   region?: 'global' | 'china' | 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 {
 export interface CloudLoginResponse {
@@ -1366,6 +1368,8 @@ export interface OrcaProfileDetail {
 export interface MakerworldStatus {
 export interface MakerworldStatus {
   has_cloud_token: boolean;
   has_cloud_token: boolean;
   can_download: 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 {
 export interface MakerworldResolvedModel {

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

@@ -3340,6 +3340,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Verbunden als',
     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',
     logout: 'Abmelden',
     noLogoutPermission: 'Sie haben keine Berechtigung zum Abmelden',
     noLogoutPermission: 'Sie haben keine Berechtigung zum Abmelden',
     failedToLoad: 'Profile konnten nicht geladen werden',
     failedToLoad: 'Profile konnten nicht geladen werden',
@@ -6499,6 +6501,8 @@ export default {
     resolveButton: 'Laden',
     resolveButton: 'Laden',
     signInRequiredTitle: 'Bambu-Cloud-Anmeldung für Download erforderlich',
     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.',
     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',
     openCloudSettings: 'Cloud-Einstellungen öffnen',
     untitledModel: 'Unbenanntes Modell',
     untitledModel: 'Unbenanntes Modell',
     byCreator: 'von {{name}}',
     byCreator: 'von {{name}}',

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

@@ -3369,6 +3369,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Connected as',
     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',
     logout: 'Logout',
     noLogoutPermission: 'You do not have permission to logout',
     noLogoutPermission: 'You do not have permission to logout',
     failedToLoad: 'Failed to load profiles',
     failedToLoad: 'Failed to load profiles',
@@ -6543,6 +6545,8 @@ export default {
     resolveButton: 'Resolve',
     resolveButton: 'Resolve',
     signInRequiredTitle: 'Bambu Cloud sign-in required to download',
     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.',
     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',
     openCloudSettings: 'Open Cloud settings',
     untitledModel: 'Untitled model',
     untitledModel: 'Untitled model',
     byCreator: 'by {{name}}',
     byCreator: 'by {{name}}',

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

@@ -3343,6 +3343,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Conectado como',
     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',
     logout: 'Cerrar sesión',
     noLogoutPermission: 'No tiene permiso para cerrar sesión',
     noLogoutPermission: 'No tiene permiso para cerrar sesión',
     failedToLoad: 'Error al cargar los perfiles',
     failedToLoad: 'Error al cargar los perfiles',
@@ -6508,6 +6510,8 @@ export default {
     resolveButton: 'Resolver',
     resolveButton: 'Resolver',
     signInRequiredTitle: 'Se requiere iniciar sesión en Bambu Cloud para descargar',
     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.',
     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',
     openCloudSettings: 'Abrir los ajustes de la nube',
     untitledModel: 'Modelo sin título',
     untitledModel: 'Modelo sin título',
     byCreator: 'de {{name}}',
     byCreator: 'de {{name}}',

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

@@ -3329,6 +3329,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Connecté en tant que',
     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',
     logout: 'Déconnexion',
     noLogoutPermission: 'Pas d\'autorisation de déconnexion',
     noLogoutPermission: 'Pas d\'autorisation de déconnexion',
     failedToLoad: 'Échec chargement profils',
     failedToLoad: 'Échec chargement profils',
@@ -6488,6 +6490,8 @@ export default {
     resolveButton: 'Résoudre',
     resolveButton: 'Résoudre',
     signInRequiredTitle: 'Connexion Bambu Cloud requise pour télécharger',
     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.',
     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',
     openCloudSettings: 'Ouvrir les paramètres Cloud',
     untitledModel: 'Modèle sans titre',
     untitledModel: 'Modèle sans titre',
     byCreator: 'par {{name}}',
     byCreator: 'par {{name}}',

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

@@ -3328,6 +3328,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Connesso come',
     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',
     logout: 'Esci',
     noLogoutPermission: 'Non hai il permesso di disconnetterti',
     noLogoutPermission: 'Non hai il permesso di disconnetterti',
     failedToLoad: 'Caricamento profili fallito',
     failedToLoad: 'Caricamento profili fallito',
@@ -6487,6 +6489,8 @@ export default {
     resolveButton: 'Risolvi',
     resolveButton: 'Risolvi',
     signInRequiredTitle: 'Accesso Bambu Cloud richiesto per scaricare',
     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.',
     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',
     openCloudSettings: 'Apri impostazioni Cloud',
     untitledModel: 'Modello senza titolo',
     untitledModel: 'Modello senza titolo',
     byCreator: 'di {{name}}',
     byCreator: 'di {{name}}',

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

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

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

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

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

@@ -3328,6 +3328,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Conectado como',
     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',
     logout: 'Sair',
     noLogoutPermission: 'Você não tem permissão para sair',
     noLogoutPermission: 'Você não tem permissão para sair',
     failedToLoad: 'Falha ao carregar perfis',
     failedToLoad: 'Falha ao carregar perfis',
@@ -6487,6 +6489,8 @@ export default {
     resolveButton: 'Resolver',
     resolveButton: 'Resolver',
     signInRequiredTitle: 'Login no Bambu Cloud necessário para baixar',
     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.',
     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',
     openCloudSettings: 'Abrir configurações do Cloud',
     untitledModel: 'Modelo sem título',
     untitledModel: 'Modelo sem título',
     byCreator: 'por {{name}}',
     byCreator: 'por {{name}}',

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

@@ -3344,6 +3344,8 @@ export default {
       },
       },
     },
     },
     connectedAs: 'Bağlı kullanıcı',
     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ış',
     logout: 'Çıkış',
     noLogoutPermission: 'Çıkış yapma izniniz yok',
     noLogoutPermission: 'Çıkış yapma izniniz yok',
     failedToLoad: 'Profiller yüklenemedi',
     failedToLoad: 'Profiller yüklenemedi',
@@ -6439,6 +6441,8 @@ export default {
     resolveButton: 'Çöz',
     resolveButton: 'Çöz',
     signInRequiredTitle: 'İndirme için Bambu Cloud girişi gerekli',
     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.',
     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ç',
     openCloudSettings: 'Bulut ayarlarını aç',
     untitledModel: 'Adsız model',
     untitledModel: 'Adsız model',
     byCreator: '{{name}} tarafından',
     byCreator: '{{name}} tarafından',

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

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

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

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

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

@@ -402,10 +402,13 @@ export function MakerworldPage() {
   const instances = resolved?.instances ?? [];
   const instances = resolved?.instances ?? [];
   const alreadyImported = (resolved?.already_imported_library_ids.length ?? 0) > 0;
   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.
   // Only block Print Now / Import actions on an import-capable login.
   // Browse/resolve works anonymously.
   // Browse/resolve works anonymously.
   const canDownload = statusQuery.data?.can_download ?? false;
   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 coverUrl = useMemo(() => pickString(design, 'coverUrl'), [design]);
   const title = pickString(design, 'title');
   const title = pickString(design, 'title');
@@ -430,17 +433,21 @@ export function MakerworldPage() {
           screens (tablet/phone), with the sidebar tucked below the main flow. */}
           screens (tablet/phone), with the sidebar tucked below the main flow. */}
       <div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
       <div className="grid gap-6 lg:grid-cols-[1fr_20rem]">
         <div className="space-y-6 min-w-0">
         <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">
         <Card className="border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20">
           <CardContent>
           <CardContent>
             <div className="flex items-start gap-3 py-2">
             <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" />
               <AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
               <div className="text-sm">
               <div className="text-sm">
                 <p className="font-medium text-amber-900 dark:text-amber-100">
                 <p className="font-medium text-amber-900 dark:text-amber-100">
-                  {t('makerworld.signInRequiredTitle')}
+                  {signInExpired
+                    ? t('makerworld.signInExpiredTitle')
+                    : t('makerworld.signInRequiredTitle')}
                 </p>
                 </p>
                 <p className="text-amber-800 dark:text-amber-200 mt-1">
                 <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">
                   <Link to="/profiles" className="underline">
                     {t('makerworld.openCloudSettings')}
                     {t('makerworld.openCloudSettings')}
                   </Link>
                   </Link>

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

@@ -2959,6 +2959,20 @@ export function ProfilesPage() {
             </div>
             </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 ? (
           {!status?.is_authenticated ? (
             <LoginForm onSuccess={handleLoginSuccess} t={t} />
             <LoginForm onSuccess={handleLoginSuccess} t={t} />
           ) : settingsLoading ? (
           ) : settingsLoading ? (

+ 7 - 0
requirements.txt

@@ -113,6 +113,13 @@ idna>=3.15
 # HTTP client (used for OIDC token exchange)
 # HTTP client (used for OIDC token exchange)
 httpx>=0.26.0
 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
 # HTTP client with browser TLS-fingerprint impersonation. Used only for
 # the bambulab.com firmware-download page in services/firmware_check.py:
 # the bambulab.com firmware-download page in services/firmware_check.py:
 # Bambu's Cloudflare WAF gates the page behind a JA3/TLS-fingerprint
 # 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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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