Преглед на файлове

fix(cloud): #1575 surface actionable error when Bambu Cloud Cloudflare challenge swallows the JSON response

  When Cloudflare in front of bambulab.com returns a "Just a moment..." interstitial
  instead of the JSON the API normally produces, the parse error in
  verify_totp / verify_code / login_request used to surface as the opaque "Invalid
  response from Bambu Cloud" or a generic 401 from BambuCloudAuthError. Reporter
  hit this with three back-to-back TOTP attempts; a curl from a different network
  with the same honest Bambuddy UA returns clean JSON, so the trigger is CF-side
  (per-IP / TLS-fingerprint / rate / transient mitigation), not our code.

  Add a small _detect_cloudflare_challenge() helper that inspects the response for
  four CF markers (body "Just a moment...", body "challenges.cloudflare.com", 403
  with cf-mitigated header, 503 with cf-ray header) and returns a message that
  attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few
  minutes, and points the user at a same-network browser sign-in as the standard
  workaround. Wired into all three JSON-parse sites; verify_totp previously had a
  defensive catch, login_request and verify_code now do too.

  No header changes, no impersonation, no retry loop - pure diagnostics. Stays
  clearly on the right side of Bambu Lab's "no falsified client identity" line.
maziggy преди 2 дни
родител
ревизия
db9b20631c
променени са 3 файла, в които са добавени 196 реда и са изтрити 5 реда
  1. 0 0
      CHANGELOG.md
  2. 62 3
      backend/app/services/bambu_cloud.py
  3. 134 2
      backend/tests/unit/services/test_bambu_cloud.py

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
CHANGELOG.md


+ 62 - 3
backend/app/services/bambu_cloud.py

@@ -22,6 +22,50 @@ BAMBU_API_BASE_CN = "https://api.bambulab.cn"
 # introduce ourselves as official Bambu Studio.
 _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
 
+# Cloudflare protection on Bambu Lab's edge intermittently returns interstitials /
+# challenges instead of the JSON the API normally produces (issue #1575). The
+# parse error that results is opaque — these helpers detect the CF markers so
+# we can surface an actionable message instead of "Invalid response from Bambu Cloud".
+_CF_INTERSTITIAL_USER_MESSAGE = (
+    "Bambu Cloud is temporarily blocking automated requests from your network. "
+    "This is a Cloudflare protection on Bambu Lab's side, not a Bambuddy issue. "
+    "Please wait a few minutes and try again. If it persists, signing in to "
+    "bambulab.com once from a browser on the same network usually clears the "
+    "challenge."
+)
+
+
+def _detect_cloudflare_challenge(response) -> str | None:
+    """Return a user-actionable message when the response is a Cloudflare
+    challenge / mitigation page instead of the JSON the API normally returns.
+
+    Triggers on any of:
+      - body contains "Just a moment..." (CF interactive challenge title)
+      - body contains "challenges.cloudflare.com" (CF turnstile widget src)
+      - HTTP 403 with a "cf-mitigated" response header (CF blocked)
+      - HTTP 503 with a "cf-ray" response header (CF Under Attack mode)
+
+    Returns None when the response doesn't look like a CF challenge — callers
+    fall through to their existing error path.
+    """
+    try:
+        body = response.text or ""
+    except Exception:
+        body = ""
+    if "Just a moment..." in body or "challenges.cloudflare.com" in body:
+        return _CF_INTERSTITIAL_USER_MESSAGE
+    try:
+        status = int(getattr(response, "status_code", 0) or 0)
+    except (TypeError, ValueError):
+        status = 0
+    headers = getattr(response, "headers", {}) or {}
+    if status == 403 and "cf-mitigated" in headers:
+        return _CF_INTERSTITIAL_USER_MESSAGE
+    if status == 503 and "cf-ray" in headers:
+        return _CF_INTERSTITIAL_USER_MESSAGE
+    return None
+
+
 # The `/v1/iot-service/api/slicer/setting` endpoint requires a `version` query
 # parameter in the XX.YY.ZZ.WW format Bambu Studio releases use (without it the
 # API returns HTTP 400 "field 'version' is not set"; non-matching formats like
@@ -114,7 +158,16 @@ class BambuCloudService:
                 },
             )
 
-            data = response.json()
+            try:
+                data = response.json()
+            except Exception as json_err:
+                logger.error("Failed to parse login response: %s, body: %s", json_err, response.text[:500])
+                cf_message = _detect_cloudflare_challenge(response)
+                return {
+                    "success": False,
+                    "needs_verification": False,
+                    "message": cf_message or "Invalid response from Bambu Cloud",
+                }
             logger.debug(
                 f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
             )
@@ -170,7 +223,12 @@ class BambuCloudService:
                 },
             )
 
-            data = response.json()
+            try:
+                data = response.json()
+            except Exception as json_err:
+                logger.error("Failed to parse email-verify response: %s, body: %s", json_err, response.text[:500])
+                cf_message = _detect_cloudflare_challenge(response)
+                return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
             logger.debug("Email verify response: status=%s, hasToken=%s", response.status_code, "accessToken" in data)
 
             if response.status_code == 200 and "accessToken" in data:
@@ -230,7 +288,8 @@ class BambuCloudService:
                 data = response.json()
             except Exception as json_err:
                 logger.error("Failed to parse TOTP response: %s, body: %s", json_err, response.text[:500])
-                return {"success": False, "message": "Invalid response from Bambu Cloud"}
+                cf_message = _detect_cloudflare_challenge(response)
+                return {"success": False, "message": cf_message or "Invalid response from Bambu Cloud"}
 
             # Token might be in accessToken, token field, or cookies
             access_token = data.get("accessToken") or data.get("token")

+ 134 - 2
backend/tests/unit/services/test_bambu_cloud.py

@@ -202,10 +202,13 @@ class TestBambuCloudTOTPVerification:
 
     @pytest.mark.asyncio
     async def test_verify_totp_cloudflare_blocked(self, cloud_service):
-        """When Cloudflare blocks request, should handle gracefully."""
+        """When Cloudflare returns a 'Just a moment...' interstitial instead of
+        JSON, surface the actionable CF-specific message (issue #1575) rather
+        than the opaque "Invalid response from Bambu Cloud" parse error."""
         mock_response = MagicMock()
         mock_response.status_code = 403
         mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
+        mock_response.headers = {}
         # json() raises an error when response is HTML
         mock_response.json.side_effect = ValueError("No JSON")
 
@@ -215,7 +218,8 @@ class TestBambuCloudTOTPVerification:
             result = await cloud_service.verify_totp("test-tfa-key", "123456")
 
             assert result["success"] is False
-            assert "Invalid response" in result["message"]
+            assert "Cloudflare" in result["message"]
+            assert "bambulab.com" in result["message"]
 
     @pytest.mark.asyncio
     async def test_verify_totp_uses_honest_bambuddy_user_agent(self, cloud_service):
@@ -305,3 +309,131 @@ class TestBambuCloudRegion:
             url = mock_post.call_args[0][0]
             assert "bambulab.cn/api/sign-in/tfa" in url
             assert "bambulab.com" not in url
+
+
+# ===========================================================================
+# Issue #1575: Cloudflare interstitial → actionable error message
+# ===========================================================================
+
+
+class TestCloudflareChallengeDetection:
+    """The _detect_cloudflare_challenge helper inspects a response and returns
+    the user-actionable message when CF returned a challenge / mitigation page
+    instead of JSON. None otherwise."""
+
+    # The actual interstitial fragment captured from issue #1575's log — keeping
+    # this verbatim so future regressions in detection are checked against the
+    # exact body shape the user hit, not a stylised copy.
+    _REPORTER_INTERSTITIAL = (
+        '<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...'
+        '</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">'
+        '<meta http-equiv="X-UA-Compatible" content="IE=Edge">'
+        '<meta name="robots" content="noindex,nofollow">'
+        '<meta name="viewport" content="width=device-width,initial-scale=1">'
+    )
+
+    def test_just_a_moment_title_in_body(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = self._REPORTER_INTERSTITIAL
+        response.status_code = 200
+        response.headers = {}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_challenges_cloudflare_com_in_body(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = (
+            '<html><body><script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script></body></html>'
+        )
+        response.status_code = 200
+        response.headers = {}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_cf_mitigated_403(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = ""
+        response.status_code = 403
+        response.headers = {"cf-mitigated": "challenge"}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_cf_ray_503(self):
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = "<html>Under attack</html>"
+        response.status_code = 503
+        response.headers = {"cf-ray": "abc-DEF"}
+        assert _detect_cloudflare_challenge(response) is not None
+
+    def test_real_json_400_is_not_a_challenge(self):
+        """Application-level 400 with the real "Login failed" JSON the API
+        normally returns must NOT be misclassified as a CF challenge — that
+        would suppress the actionable upstream error."""
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = '{"code":5,"error":"Login failed"}'
+        response.status_code = 400
+        response.headers = {"cf-ray": "abc-DEF", "server": "cloudflare"}
+        assert _detect_cloudflare_challenge(response) is None
+
+    def test_message_mentions_bambu_lab_and_cloudflare(self):
+        """The message must clearly attribute the block to Bambu Lab's
+        Cloudflare protection — not to Bambuddy — so users know what to do."""
+        from backend.app.services.bambu_cloud import _detect_cloudflare_challenge
+
+        response = MagicMock()
+        response.text = "<title>Just a moment...</title>"
+        response.status_code = 200
+        response.headers = {}
+        msg = _detect_cloudflare_challenge(response)
+        assert msg is not None
+        assert "Cloudflare" in msg
+        assert "bambulab.com" in msg
+
+    @pytest.mark.asyncio
+    async def test_verify_code_surfaces_cf_message_on_interstitial(self):
+        """verify_code (email-code path) must surface the CF message when the
+        endpoint returns an HTML interstitial — same shape as verify_totp."""
+        cloud = BambuCloudService()
+
+        mock_response = MagicMock()
+        mock_response.status_code = 403
+        mock_response.text = self._REPORTER_INTERSTITIAL
+        mock_response.headers = {}
+        mock_response.json.side_effect = ValueError("No JSON")
+
+        with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud.verify_code("test@example.com", "123456")
+
+            assert result["success"] is False
+            assert "Cloudflare" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_login_request_surfaces_cf_message_on_interstitial(self):
+        """login_request must surface the CF message when the endpoint returns
+        an HTML interstitial. Previously the parse error bubbled to
+        BambuCloudAuthError with an opaque "Expecting value..." detail."""
+        cloud = BambuCloudService()
+
+        mock_response = MagicMock()
+        mock_response.status_code = 403
+        mock_response.text = self._REPORTER_INTERSTITIAL
+        mock_response.headers = {}
+        mock_response.json.side_effect = ValueError("No JSON")
+
+        with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud.login_request("test@example.com", "password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is False
+            assert "Cloudflare" in result["message"]

Някои файлове не бяха показани, защото твърде много файлове са промени