|
@@ -20,6 +20,26 @@ from backend.app.core.config import _data_dir
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
+# Cloudflare on bambulab.com now gates the firmware-download page behind a
|
|
|
|
|
+# JA3/TLS-fingerprint challenge (cf-mitigated=challenge) that plain Python
|
|
|
|
|
+# TLS can't pass (#1666). curl_cffi replays Chrome's actual ClientHello
|
|
|
|
|
+# bytes so the handshake clears CF; we override the HTTP User-Agent back to
|
|
|
|
|
+# the honest Bambuddy/1.0 string so the application-layer identity stays
|
|
|
|
|
+# truthful (TLS fingerprint matches Chrome because Python's TLS is the
|
|
|
|
|
+# signal CF gates on; everything above TLS is still Bambuddy).
|
|
|
|
|
+#
|
|
|
|
|
+# Soft dependency — if curl_cffi isn't importable on the running platform,
|
|
|
|
|
+# firmware_check degrades to httpx (which will likely 403) and wiki-based
|
|
|
|
|
+# version detection continues to work for the badge; only the in-app
|
|
|
|
|
+# firmware-download URL stops resolving.
|
|
|
|
|
+try:
|
|
|
|
|
+ from curl_cffi.requests import AsyncSession as _CurlCffiAsyncSession
|
|
|
|
|
+
|
|
|
|
|
+ _CURL_CFFI_AVAILABLE = True
|
|
|
|
|
+except ImportError: # pragma: no cover — exercised only on platforms without wheels
|
|
|
|
|
+ _CurlCffiAsyncSession = None # type: ignore[misc,assignment]
|
|
|
|
|
+ _CURL_CFFI_AVAILABLE = False
|
|
|
|
|
+
|
|
|
# Bambu Lab firmware download page (for download URLs)
|
|
# Bambu Lab firmware download page (for download URLs)
|
|
|
BAMBU_FIRMWARE_BASE = "https://bambulab.com"
|
|
BAMBU_FIRMWARE_BASE = "https://bambulab.com"
|
|
|
FIRMWARE_PAGE = "/en/support/firmware-download/all"
|
|
FIRMWARE_PAGE = "/en/support/firmware-download/all"
|
|
@@ -121,22 +141,71 @@ class FirmwareCheckService:
|
|
|
self._version_cache: dict[str, FirmwareVersion] = {}
|
|
self._version_cache: dict[str, FirmwareVersion] = {}
|
|
|
self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
|
|
self._versions_list_cache: dict[str, list[FirmwareVersion]] = {}
|
|
|
self._cache_time: float = 0
|
|
self._cache_time: float = 0
|
|
|
|
|
+ # Plain httpx client for the Bambu Lab wiki (no CF fingerprint check)
|
|
|
|
|
+ # and other endpoints. Honest UA throughout.
|
|
|
self._client = httpx.AsyncClient(
|
|
self._client = httpx.AsyncClient(
|
|
|
timeout=30.0,
|
|
timeout=30.0,
|
|
|
headers={
|
|
headers={
|
|
|
- # Identify honestly as Bambuddy when scraping the public Bambu
|
|
|
|
|
- # 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": "text/html,application/json,*/*;q=0.8",
|
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
|
},
|
|
},
|
|
|
)
|
|
)
|
|
|
|
|
+ # curl_cffi session for bambulab.com (lazily initialised on first use).
|
|
|
|
|
+ # See module-level note on why this is needed.
|
|
|
|
|
+ self._bambulab_client: object | None = None
|
|
|
|
|
+ if not _CURL_CFFI_AVAILABLE:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "curl_cffi not installed — bambulab.com firmware-download page "
|
|
|
|
|
+ "will likely return Cloudflare 403 (#1666). Wiki-based version "
|
|
|
|
|
+ "detection still works; install curl_cffi to also resolve "
|
|
|
|
|
+ "in-app firmware download URLs."
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _get_bambulab_client(self) -> object | None:
|
|
|
|
|
+ """Lazy-init curl_cffi async session for bambulab.com.
|
|
|
|
|
+
|
|
|
|
|
+ Chrome TLS impersonation is required to pass Cloudflare's JA3
|
|
|
|
|
+ challenge. The HTTP `User-Agent` is overridden back to the honest
|
|
|
|
|
+ Bambuddy string so application-layer identity stays truthful.
|
|
|
|
|
+ Returns None when curl_cffi is unavailable.
|
|
|
|
|
+ """
|
|
|
|
|
+ if not _CURL_CFFI_AVAILABLE:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if self._bambulab_client is None:
|
|
|
|
|
+ assert _CurlCffiAsyncSession is not None # type-narrow for mypy
|
|
|
|
|
+ self._bambulab_client = _CurlCffiAsyncSession(
|
|
|
|
|
+ impersonate="chrome",
|
|
|
|
|
+ headers={
|
|
|
|
|
+ "User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)",
|
|
|
|
|
+ "Accept": "text/html,application/json,*/*;q=0.8",
|
|
|
|
|
+ "Accept-Language": "en-US,en;q=0.9",
|
|
|
|
|
+ },
|
|
|
|
|
+ timeout=30,
|
|
|
|
|
+ )
|
|
|
|
|
+ return self._bambulab_client
|
|
|
|
|
+
|
|
|
|
|
+ async def _bambulab_get(self, url: str) -> tuple[int, str]:
|
|
|
|
|
+ """GET against bambulab.com. Returns (status_code, body_text).
|
|
|
|
|
+
|
|
|
|
|
+ Routes through curl_cffi when available (passes the CF JA3 gate);
|
|
|
|
|
+ falls back to httpx otherwise (will likely 403 but worth the try
|
|
|
|
|
+ in case CF eases the check). status 0 indicates a transport error.
|
|
|
|
|
+ """
|
|
|
|
|
+ client = self._get_bambulab_client()
|
|
|
|
|
+ if client is not None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ response = await client.get(url) # type: ignore[attr-defined]
|
|
|
|
|
+ return response.status_code, response.text
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error("curl_cffi error fetching %s: %s", url, e)
|
|
|
|
|
+ return 0, ""
|
|
|
|
|
+ try:
|
|
|
|
|
+ response = await self._client.get(url)
|
|
|
|
|
+ return response.status_code, response.text
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error("httpx error fetching %s: %s", url, e)
|
|
|
|
|
+ return 0, ""
|
|
|
|
|
|
|
|
def _build_id_cache_path(self) -> Path:
|
|
def _build_id_cache_path(self) -> Path:
|
|
|
cache_dir = _data_dir / "firmware"
|
|
cache_dir = _data_dir / "firmware"
|
|
@@ -188,28 +257,29 @@ class FirmwareCheckService:
|
|
|
self._build_id_time = disk_time
|
|
self._build_id_time = disk_time
|
|
|
|
|
|
|
|
# 3. Live fetch
|
|
# 3. Live fetch
|
|
|
- try:
|
|
|
|
|
- response = await self._client.get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
|
|
|
|
|
- if response.status_code == 200:
|
|
|
|
|
- match = re.search(r'"buildId":"([^"]+)"', response.text)
|
|
|
|
|
- if match:
|
|
|
|
|
- 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()
|
|
|
|
|
- self._download_page_unreachable = False
|
|
|
|
|
- self._save_build_id_to_disk(new_build_id)
|
|
|
|
|
- return self._build_id
|
|
|
|
|
- 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)
|
|
|
|
|
|
|
+ status, body = await self._bambulab_get(f"{BAMBU_FIRMWARE_BASE}{FIRMWARE_PAGE}")
|
|
|
|
|
+ if status == 200:
|
|
|
|
|
+ match = re.search(r'"buildId":"([^"]+)"', body)
|
|
|
|
|
+ if match:
|
|
|
|
|
+ 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()
|
|
|
|
|
+ self._download_page_unreachable = False
|
|
|
|
|
+ self._save_build_id_to_disk(new_build_id)
|
|
|
|
|
+ return self._build_id
|
|
|
|
|
+ elif status == 0:
|
|
|
|
|
+ # Transport-level error already logged by _bambulab_get.
|
|
|
|
|
+ self._download_page_unreachable = True
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 403/5xx — keep stale cached buildId if we have one (#1350).
|
|
|
|
|
+ # Without curl_cffi this is the expected outcome on Cloudflare
|
|
|
|
|
+ # JA3-gated zones (#1666).
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "Failed to get Bambu Lab page: %s (will try cached buildId if available)",
|
|
|
|
|
+ status,
|
|
|
|
|
+ )
|
|
|
self._download_page_unreachable = True
|
|
self._download_page_unreachable = True
|
|
|
|
|
|
|
|
# Return whatever we have — even a stale buildId beats nothing.
|
|
# Return whatever we have — even a stale buildId beats nothing.
|
|
@@ -302,50 +372,49 @@ class FirmwareCheckService:
|
|
|
return []
|
|
return []
|
|
|
|
|
|
|
|
for attempt in range(2):
|
|
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
|
|
|
|
|
|
|
+ url = f"{BAMBU_FIRMWARE_BASE}/_next/data/{build_id}/en/support/firmware-download/{api_key}.json"
|
|
|
|
|
+ status, body = await self._bambulab_get(url)
|
|
|
|
|
|
|
|
- # 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
|
|
|
|
|
|
|
+ if status == 200:
|
|
|
|
|
+ try:
|
|
|
|
|
+ data = json.loads(body)
|
|
|
|
|
+ except ValueError as e:
|
|
|
|
|
+ logger.debug("Download-page JSON for %s parse error: %s", api_key, e)
|
|
|
|
|
+ return []
|
|
|
|
|
+ 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 status == 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
|
|
|
|
|
|
|
|
- logger.debug("Download-page JSON for %s returned status %s", api_key, response.status_code)
|
|
|
|
|
- return []
|
|
|
|
|
|
|
+ # 403 from the JSON endpoint is the same Cloudflare block
|
|
|
|
|
+ # signal as on the index page (#1350, #1666).
|
|
|
|
|
+ if status == 403:
|
|
|
|
|
+ self._download_page_unreachable = True
|
|
|
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
|
|
|
|
|
- return []
|
|
|
|
|
|
|
+ logger.debug("Download-page JSON for %s returned status %s", api_key, status)
|
|
|
|
|
+ return []
|
|
|
|
|
|
|
|
return []
|
|
return []
|
|
|
|
|
|