Browse Source

fix(firmware): keep download-URL resolution working when bambulab.com 403s (#1350)

  The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
  misleading error "Firmware file is not available from Bambu Lab" while the
  logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
  only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
  on the reporter's network, and the buildId was cached in memory only, so a
  single 403 broke download-URL resolution for the rest of the session.

  - Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
    so the request stops tripping Cloudflare's "bare scraper" signal.
  - Persist the buildId to <data_dir>/firmware/build_id.json so a transient
    403 or a backend restart can't wipe a previously-valid buildId.
  - Add a download_page_unreachable flag and use it in the prepare-update flow
    to render an honest error ("page unreachable from this network — try later
    or download manually from bambulab.com") instead of implying Bambu doesn't
    have the file.
  - Retry the per-model JSON once when a cached buildId returns 404 (page
    rebuild), give up gracefully on 403 without churning.
maziggy 1 week ago
parent
commit
405dd1525b

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 132 - 30
backend/app/services/firmware_check.py

@@ -6,6 +6,7 @@ download page. The wiki is used as the primary version source (always up-to-date
 while the download page provides firmware file URLs for offline updates.
 """
 
+import json
 import logging
 import re
 import time
@@ -116,6 +117,7 @@ class FirmwareCheckService:
     def __init__(self):
         self._build_id: str | None = None
         self._build_id_time: float = 0
+        self._download_page_unreachable: bool = False
         self._version_cache: dict[str, FirmwareVersion] = {}
         self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
         self._cache_time: float = 0
@@ -126,31 +128,103 @@ class FirmwareCheckService:
                 # Lab firmware wiki — verified 2026-05-12 that the wiki serves
                 # this UA identically to a Chrome UA (same HTML response shape).
                 # No browser impersonation needed for read-only public pages.
-                "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
+                "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
+                # Some Cloudflare bot rules on bambulab.com 403 requests with a
+                # bare UA but no browser-like Accept headers (seen on AU IPs in
+                # #1350). Sending normal Accept hints removes that signal while
+                # staying honestly identified via the UA above.
+                "Accept": "text/html,application/json,*/*;q=0.8",
+                "Accept-Language": "en-US,en;q=0.9",
             },
         )
 
+    def _build_id_cache_path(self) -> Path:
+        cache_dir = _data_dir / "firmware"
+        cache_dir.mkdir(parents=True, exist_ok=True)
+        return cache_dir / "build_id.json"
+
+    def _load_build_id_from_disk(self) -> tuple[str | None, float]:
+        """Load the last-known buildId from disk, returning (build_id, fetched_at)."""
+        path = self._build_id_cache_path()
+        try:
+            if not path.exists():
+                return None, 0.0
+            data = json.loads(path.read_text())
+            build_id = data.get("build_id")
+            fetched_at = float(data.get("fetched_at", 0))
+            if isinstance(build_id, str) and build_id:
+                return build_id, fetched_at
+        except (OSError, ValueError, TypeError) as e:
+            logger.debug("Could not read cached buildId: %s", e)
+        return None, 0.0
+
+    def _save_build_id_to_disk(self, build_id: str) -> None:
+        try:
+            self._build_id_cache_path().write_text(json.dumps({"build_id": build_id, "fetched_at": time.time()}))
+        except OSError as e:
+            logger.debug("Could not persist buildId: %s", e)
+
     async def _get_build_id(self) -> str | None:
-        """Fetch the Next.js build ID from Bambu Lab's firmware page."""
-        # Use cached build ID if still valid (cache for 1 hour)
+        """Fetch the Next.js build ID from Bambu Lab's firmware page.
+
+        Cache layers (fresh → stale → none):
+        1. In-memory (1 hour TTL) — fast path for repeated checks in a session
+        2. Disk-cached buildId (any age) — survives restarts, lets us recover
+           from upstream Cloudflare 403s. The buildId is treated as
+           "probably still valid" because Bambu rebuilds the page only every
+           few weeks; if the JSON fetch later fails, the caller falls back.
+        3. Live fetch from bambulab.com — only when both caches miss
+        """
+        # 1. In-memory cache (fresh)
         if self._build_id and (time.time() - self._build_id_time) < CACHE_TTL:
             return self._build_id
 
+        # 2. Disk cache: load if we don't have one in memory yet (first call
+        #    after restart). We still try the live fetch below to refresh.
+        if not self._build_id:
+            disk_id, disk_time = self._load_build_id_from_disk()
+            if disk_id:
+                self._build_id = disk_id
+                self._build_id_time = disk_time
+
+        # 3. Live fetch
         try:
             response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
             if response.status_code == 200:
-                # Extract buildId from the page
                 match = re.search(r'"buildId":"([^"]+)"', response.text)
                 if match:
-                    self._build_id = match.group(1)
+                    new_build_id = match.group(1)
+                    if new_build_id != self._build_id:
+                        logger.info("Got Bambu Lab build ID: %s", new_build_id)
+                    self._build_id = new_build_id
                     self._build_id_time = time.time()
-                    logger.info("Got Bambu Lab build ID: %s", self._build_id)
+                    self._download_page_unreachable = False
+                    self._save_build_id_to_disk(new_build_id)
                     return self._build_id
-            logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
+            else:
+                # 403/5xx — keep stale cached buildId if we have one (#1350).
+                logger.warning(
+                    "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
+                    response.status_code,
+                )
+                self._download_page_unreachable = True
         except Exception as e:
             logger.error("Error fetching Bambu Lab build ID: %s", e)
+            self._download_page_unreachable = True
 
-        return self._build_id  # Return cached value if available
+        # Return whatever we have — even a stale buildId beats nothing.
+        return self._build_id
+
+    @property
+    def download_page_unreachable(self) -> bool:
+        """True if the most recent attempt to reach bambulab.com firmware page failed.
+
+        Used by callers (e.g. the firmware update prepare flow) to render a
+        clearer error message when a wiki-listed version has no download URL
+        because we couldn't reach Bambu Lab, vs the version genuinely not
+        being on the catalog (#1350).
+        """
+        return self._download_page_unreachable
 
     async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
         """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
@@ -216,34 +290,62 @@ class FirmwareCheckService:
         return []
 
     async def _fetch_all_versions_from_download_page(self, api_key: str) -> list[FirmwareVersion]:
-        """Fetch all firmware versions from Bambu Lab's download page (newest first)."""
+        """Fetch all firmware versions from Bambu Lab's download page (newest first).
+
+        If we have a stale (disk-cached) buildId and it returns 404 (Bambu
+        rebuilt the page), retry once with a fresh fetch — this only kicks in
+        when the in-memory cache thinks it's still valid but the upstream has
+        moved on.
+        """
         build_id = await self._get_build_id()
         if not build_id:
             return []
 
-        try:
-            url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
-            response = await self._client.get(url)
+        for attempt in range(2):
+            try:
+                url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
+                response = await self._client.get(url)
+
+                if response.status_code == 200:
+                    data = response.json()
+                    page_props = data.get("pageProps", {})
+                    printer_map = page_props.get("printerMap", {})
+                    printer_data = printer_map.get(api_key, {})
+                    versions = printer_data.get("versions", [])
+                    return [
+                        FirmwareVersion(
+                            version=v.get("version", ""),
+                            download_url=v.get("url", ""),
+                            release_notes=v.get("release_notes_en"),
+                            release_time=v.get("release_time"),
+                        )
+                        for v in versions
+                        if v.get("version")
+                    ]
+
+                # 404 with cached buildId → Bambu rebuilt the page; invalidate
+                # and retry once. Other status codes (403, 5xx) are upstream
+                # blocks — don't churn.
+                if response.status_code == 404 and attempt == 0:
+                    logger.info("Cached Bambu buildId stale (404), refreshing")
+                    self._build_id = None
+                    self._build_id_time = 0
+                    build_id = await self._get_build_id()
+                    if not build_id:
+                        return []
+                    continue
 
-            if response.status_code == 200:
-                data = response.json()
-                page_props = data.get("pageProps", {})
-                printer_map = page_props.get("printerMap", {})
-                printer_data = printer_map.get(api_key, {})
-                versions = printer_data.get("versions", [])
-                return [
-                    FirmwareVersion(
-                        version=v.get("version", ""),
-                        download_url=v.get("url", ""),
-                        release_notes=v.get("release_notes_en"),
-                        release_time=v.get("release_time"),
-                    )
-                    for v in versions
-                    if v.get("version")
-                ]
+                # 403 from the JSON endpoint is the same Cloudflare block
+                # signal as on the index page (#1350).
+                if response.status_code == 403:
+                    self._download_page_unreachable = True
 
-        except Exception as e:
-            logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
+                logger.debug("Download-page JSON for %s returned status %s", api_key, response.status_code)
+                return []
+
+            except Exception as e:
+                logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
+                return []
 
         return []
 

+ 14 - 2
backend/app/services/firmware_update.py

@@ -172,8 +172,20 @@ class FirmwareUpdateService:
             # We'll get actual size during download
             result["firmware_size"] = 100 * 1024 * 1024  # 100MB estimate
         elif target_version:
-            # Requested specific version has no download URL
-            result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
+            # Requested specific version has no download URL. Distinguish
+            # "Bambu doesn't list this file" from "we couldn't reach Bambu's
+            # download page" (Cloudflare 403 reported in #1350) so users in
+            # affected regions get an actionable error instead of believing
+            # the firmware doesn't exist.
+            if firmware_service.download_page_unreachable:
+                result["errors"].append(
+                    f"Could not reach Bambu Lab's firmware download page to fetch the file URL for "
+                    f"{target_version}. Version is listed on the Bambu wiki but the download endpoint "
+                    f"is unreachable from this network. Try again later, or download the firmware "
+                    f"manually from bambulab.com and copy it to the printer's SD card."
+                )
+            else:
+                result["errors"].append(f"Firmware file for {target_version} is not available from Bambu Lab")
 
         # If a target version is requested, allow proceeding even if it equals or
         # is older than the current version (explicit downgrade/reinstall).

+ 134 - 1
backend/tests/unit/test_firmware_versions.py

@@ -6,9 +6,11 @@ Covers:
   (incidental version-like strings in release-note prose must be ignored).
 - Merging wiki + download-page versions produces a single list where
   wiki-only versions are flagged as unavailable (no download URL).
+- buildId disk-persistence + 403 fallback for #1350.
 """
 
-from unittest.mock import AsyncMock, patch
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
@@ -159,6 +161,137 @@ async def test_get_available_versions_sorts_newest_first():
     assert [v.version for v in result] == ["01.03.00.00", "01.02.10.00", "01.02.02.00"]
 
 
+@pytest.mark.asyncio
+async def test_client_headers_identify_honestly_and_send_browser_accept():
+    """
+    The httpx client must identify as Bambuddy (no Chrome impersonation) and
+    must send Accept + Accept-Language so Cloudflare on bambulab.com doesn't
+    403 us for looking like a bare scraper (#1350).
+    """
+    svc = FirmwareCheckService()
+    headers = svc._client.headers
+    assert headers["User-Agent"].startswith("Bambuddy/")
+    assert "Chrome" not in headers["User-Agent"]
+    assert "Accept" in headers
+    assert "Accept-Language" in headers
+
+
+@pytest.mark.asyncio
+async def test_build_id_is_persisted_to_disk(tmp_path, monkeypatch):
+    """Successful buildId fetch writes to disk so it survives restart (#1350)."""
+    monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
+
+    svc = FirmwareCheckService()
+    mock_resp = MagicMock()
+    mock_resp.status_code = 200
+    mock_resp.text = 'window.__data = {"buildId":"abc123xyz","other":"stuff"}'
+
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        build_id = await svc._get_build_id()
+
+    assert build_id == "abc123xyz"
+    cache_file = tmp_path / "firmware" / "build_id.json"
+    assert cache_file.exists()
+    data = json.loads(cache_file.read_text())
+    assert data["build_id"] == "abc123xyz"
+    assert data["fetched_at"] > 0
+
+
+@pytest.mark.asyncio
+async def test_build_id_falls_back_to_disk_on_403(tmp_path, monkeypatch):
+    """
+    When bambulab.com 403s (Cloudflare block reported in #1350) we must
+    fall back to the disk-cached buildId from the previous successful fetch.
+    Without this the user's screenshots happen: wiki version is detected
+    but the download URL stays empty forever.
+    """
+    monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
+
+    # Pre-seed a previously-saved buildId
+    cache_dir = tmp_path / "firmware"
+    cache_dir.mkdir(parents=True)
+    (cache_dir / "build_id.json").write_text(json.dumps({"build_id": "cached_id_42", "fetched_at": 1000.0}))
+
+    svc = FirmwareCheckService()
+    mock_resp = MagicMock()
+    mock_resp.status_code = 403
+    mock_resp.text = "<html>Access denied</html>"
+
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        build_id = await svc._get_build_id()
+
+    assert build_id == "cached_id_42"
+    assert svc.download_page_unreachable is True
+
+
+@pytest.mark.asyncio
+async def test_download_page_unreachable_flag_set_on_403_json(tmp_path, monkeypatch):
+    """A 403 on the per-model JSON endpoint also marks the page unreachable."""
+    monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
+
+    svc = FirmwareCheckService()
+    svc._build_id = "stale_id"
+    svc._build_id_time = 9999999999.0  # never expires for this test
+
+    mock_resp = MagicMock()
+    mock_resp.status_code = 403
+    mock_resp.text = "Forbidden"
+
+    with patch.object(svc._client, "get", AsyncMock(return_value=mock_resp)):
+        result = await svc._fetch_all_versions_from_download_page("x1")
+
+    assert result == []
+    assert svc.download_page_unreachable is True
+
+
+@pytest.mark.asyncio
+async def test_download_page_retries_once_when_buildid_stale(tmp_path, monkeypatch):
+    """
+    If the cached buildId returns 404 (Bambu rebuilt the page), refresh the
+    buildId once and retry — but don't churn on repeated failures.
+    """
+    monkeypatch.setattr("backend.app.services.firmware_check._data_dir", tmp_path)
+
+    svc = FirmwareCheckService()
+    svc._build_id = "stale_id"
+    svc._build_id_time = 9999999999.0
+
+    # First call (with stale buildId) → 404
+    # Second call (with fresh buildId after refresh) → 200 with versions
+    stale_resp = MagicMock(status_code=404, text="not found")
+    fresh_resp = MagicMock(status_code=200)
+    fresh_resp.json = MagicMock(
+        return_value={
+            "pageProps": {
+                "printerMap": {
+                    "x1": {
+                        "versions": [
+                            {
+                                "version": "01.11.02.00",
+                                "url": "https://cdn/fw.bin",
+                                "release_notes_en": "n",
+                                "release_time": "2025-12-10",
+                            }
+                        ]
+                    }
+                }
+            }
+        }
+    )
+    # Page fetch for the buildId refresh
+    page_resp = MagicMock(status_code=200)
+    page_resp.text = 'foo "buildId":"fresh_id" bar'
+
+    # Sequence: stale 404 → page refresh 200 → fresh 200
+    get = AsyncMock(side_effect=[stale_resp, page_resp, fresh_resp])
+    with patch.object(svc._client, "get", get):
+        result = await svc._fetch_all_versions_from_download_page("x1")
+
+    assert len(result) == 1
+    assert result[0].version == "01.11.02.00"
+    assert svc._build_id == "fresh_id"
+
+
 @pytest.mark.asyncio
 async def test_check_for_update_includes_available_versions():
     svc = FirmwareCheckService()

Some files were not shown because too many files changed in this diff