Files
maziggy 604fa44593 Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.

Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.

is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.

login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.

Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.

The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.

MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.

The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.

Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
2026-08-08 10:04:55 +02:00

148 lines
5.0 KiB
Python

from typing import Literal
from pydantic import BaseModel, Field
Region = Literal["global", "china"]
class CloudLoginRequest(BaseModel):
"""Request to initiate cloud login."""
email: str = Field(..., description="Bambu Lab account email")
password: str = Field(..., description="Account password")
region: Region = Field(default="global", description="Region: 'global' or 'china'")
class CloudVerifyRequest(BaseModel):
"""Request to verify login with 2FA code (email or TOTP)."""
email: str = Field(..., description="Bambu Lab account email")
code: str = Field(..., description="6-digit verification code")
tfa_key: str | None = Field(None, description="TFA key for TOTP verification (from login response)")
region: Region = Field(default="global", description="Region: 'global' or 'china'")
class CloudLoginResponse(BaseModel):
"""Response from login attempt."""
success: bool
needs_verification: bool = False
message: str
verification_type: str | None = None # "email" or "totp"
tfa_key: str | None = None # Key needed for TOTP verification
# Machine-readable cause of a failure, when we know it. Currently only
# "captcha" — Bambu's anti-abuse layer is challenging this network and no
# credential will be accepted until it clears (#2790). The UI needs this to
# explain the situation in place, rather than flashing ``message`` as a
# toast that vanishes and leaves the user retrying a password that is fine.
reason: str | None = None
class CloudAuthStatus(BaseModel):
"""Current authentication status."""
is_authenticated: bool
email: str | None = None
region: Region | None = None
# True when a token is stored but Bambu no longer accepts it. Both this and
# "never signed in" render the login form, but only this one warrants
# telling the user why it came back.
sign_in_expired: bool = False
class CloudTokenRequest(BaseModel):
"""Request to set access token directly."""
access_token: str = Field(..., description="Bambu Lab access token")
region: Region = Field(default="global", description="Region: 'global' or 'china'")
class SlicerSetting(BaseModel):
"""A slicer setting/preset."""
setting_id: str
name: str
type: str # filament, printer, process
version: str | None = None
user_id: str | None = None
updated_time: str | None = None
is_custom: bool = False
class SlicerSettingsResponse(BaseModel):
"""Response containing slicer settings."""
filament: list[SlicerSetting] = []
printer: list[SlicerSetting] = []
process: list[SlicerSetting] = []
class CloudDevice(BaseModel):
"""A bound printer device."""
dev_id: str
name: str
dev_model_name: str | None = None
dev_product_name: str | None = None
online: bool = False
class SlicerSettingCreate(BaseModel):
"""Request to create a new slicer preset."""
type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
name: str = Field(..., description="Display name for the preset")
base_id: str = Field(..., description="Base preset ID to inherit from")
version: str = Field(default="2.0.0.0", description="Version string for the preset")
setting: dict = Field(default_factory=dict, description="Setting key-value pairs (delta from base)")
class SlicerSettingUpdate(BaseModel):
"""Request to update an existing slicer preset."""
name: str | None = Field(None, description="New display name")
setting: dict | None = Field(None, description="Setting key-value pairs to update")
class SlicerSettingDetail(BaseModel):
"""Detailed slicer setting/preset response."""
message: str | None = None
code: str | None = None
error: str | None = None
public: bool = False
version: str | None = None
type: str
name: str
update_time: str | None = None
nickname: str | None = None
base_id: str | None = None
setting: dict = Field(default_factory=dict)
filament_id: str | None = None
setting_id: str | None = None # For response after create
class SlicerSettingDeleteResponse(BaseModel):
"""Response from deleting a preset."""
success: bool
message: str
class FirmwareUpdateInfo(BaseModel):
"""Firmware update information for a device."""
device_id: str = Field(..., description="Device ID")
device_name: str = Field(..., description="Device name")
current_version: str | None = Field(None, description="Currently installed firmware version")
latest_version: str | None = Field(None, description="Latest available firmware version")
update_available: bool = Field(False, description="Whether an update is available")
release_notes: str | None = Field(None, description="Release notes for the latest version")
class FirmwareUpdatesResponse(BaseModel):
"""Response containing firmware updates for all devices."""
updates: list[FirmwareUpdateInfo] = Field(default_factory=list)
updates_available: int = Field(0, description="Total number of devices with updates available")