bambu_cloud_credentials.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. """Bambu Cloud credential storage.
  2. Single seam for reading and bookkeeping the stored Bambu Cloud bearer token:
  3. per-user columns when auth is enabled, global ``Settings`` rows otherwise
  4. (auth-disabled single-user installs). Lives in the services layer so feature
  5. packages (e.g. ``model_providers``) can consume credentials without importing
  6. the route layer — routes are just one consumer among several here.
  7. """
  8. from __future__ import annotations
  9. import logging
  10. from datetime import datetime, timezone
  11. from sqlalchemy import select, update
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.database import async_session
  14. from backend.app.models.settings import Settings
  15. from backend.app.models.user import User
  16. logger = logging.getLogger(__name__)
  17. # Keys for storing cloud credentials in settings
  18. CLOUD_TOKEN_KEY = "bambu_cloud_token"
  19. CLOUD_EMAIL_KEY = "bambu_cloud_email"
  20. CLOUD_REGION_KEY = "bambu_cloud_region"
  21. # Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
  22. # an ISO timestamp; absent/empty means "not known to be dead".
  23. CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
  24. def _normalise_region(region: str | None) -> str:
  25. """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
  26. return region if region in ("global", "china") else "global"
  27. async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
  28. """Whether the stored Bambu token is known to have been rejected.
  29. Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
  30. cleared on a fresh login/logout. This is the only durable record we have:
  31. Bambu's access token is opaque (no readable expiry) and Bambuddy does not
  32. persist the refresh token, so without this flag a dead credential looks
  33. exactly like a live one.
  34. """
  35. if user is not None:
  36. return user.cloud_token_invalid_at is not None
  37. result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
  38. row = result.scalar_one_or_none()
  39. return bool(row and row.value)
  40. async def mark_cloud_token_invalid(user_id: int | None) -> None:
  41. """Record that Bambu rejected the stored token.
  42. Opens its own session on purpose. This runs from
  43. ``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
  44. is about to fail — writing through that route's session would tie the flag
  45. to a transaction the route may still roll back, and the fact that the
  46. credential is dead is true regardless of how the request ends.
  47. Best-effort: a bookkeeping failure must never replace the 401 the caller
  48. actually needs to see. ``user_id=None`` (auth-disabled single-user setup)
  49. records the global flag — those installs *do* hold a token
  50. (:func:`get_stored_token` reads it from ``Settings``), so the rejection
  51. must land somewhere the status endpoints can see it.
  52. """
  53. now = datetime.now(timezone.utc)
  54. try:
  55. async with async_session() as db:
  56. if user_id is not None:
  57. await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
  58. else:
  59. result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
  60. row = result.scalar_one_or_none()
  61. if row:
  62. row.value = now.isoformat()
  63. else:
  64. db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
  65. await db.commit()
  66. logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
  67. except Exception:
  68. logger.exception("Could not record the Bambu Cloud token as invalid")
  69. async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
  70. """Clear the rejected-token flag — called on every fresh login and logout."""
  71. if user is not None:
  72. await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
  73. return
  74. result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
  75. row = result.scalar_one_or_none()
  76. if row:
  77. await db.delete(row)
  78. async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
  79. """Get stored cloud token, email, and region.
  80. When a user is provided (auth enabled), returns that user's per-user credentials.
  81. When user is None (auth disabled), falls back to global Settings table.
  82. Region defaults to ``"global"`` when unset (including for rows that predate the
  83. ``cloud_region`` column).
  84. """
  85. if user is not None:
  86. return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
  87. # Fallback: global storage (auth disabled)
  88. result = await db.execute(
  89. select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
  90. )
  91. settings = {s.key: s.value for s in result.scalars().all()}
  92. return (
  93. settings.get(CLOUD_TOKEN_KEY),
  94. settings.get(CLOUD_EMAIL_KEY),
  95. _normalise_region(settings.get(CLOUD_REGION_KEY)),
  96. )