github_backup.py 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046
  1. """GitHub backup service for printer profiles.
  2. Handles scheduled and on-demand backups of K-profiles and cloud profiles to GitHub.
  3. """
  4. import asyncio
  5. import logging
  6. from datetime import datetime, timedelta, timezone
  7. import httpx
  8. from sqlalchemy import or_, select
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from backend.app.core.database import async_session
  11. from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN, redact_refused_headers
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.archive_alias import ArchiveAlias
  14. from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
  15. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
  16. from backend.app.models.printer import Printer
  17. from backend.app.models.settings import Settings
  18. from backend.app.models.spool import Spool
  19. from backend.app.models.spool_usage_history import SpoolUsageHistory
  20. from backend.app.models.user import User
  21. from backend.app.services.git_providers.factory import get_provider_backend
  22. from backend.app.services.github_restore import is_restorable_setting_key
  23. from backend.app.services.printer_manager import printer_manager
  24. from backend.app.services.secret_fields import SECRET_PLACEHOLDER, is_secret_setting_key, mask_preset
  25. logger = logging.getLogger(__name__)
  26. # Bambu's listing endpoint is keyed by preset type and calls process presets
  27. # "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
  28. # divergence here silently drops a whole preset type from every backup.
  29. _BAMBU_PRESET_TYPES = {
  30. "filament": "filament",
  31. "printer": "printer",
  32. "print": "process",
  33. }
  34. def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
  35. """One Bambu preset as stored in the backup: metadata plus the payload.
  36. ``base_id`` and ``setting`` are the two fields ``BambuCloudService.
  37. create_setting`` needs, so a restore can rebuild the preset rather than
  38. just list it.
  39. ``user_id`` from the listing is deliberately dropped. It identifies the
  40. account and adds nothing to a rebuild, and backup repositories can be
  41. public. A printer connection's credentials in ``setting`` are shown as
  42. the placeholder.
  43. """
  44. return {
  45. "setting_id": str(setting_id),
  46. "name": detail.get("name") or entry.get("name") or "Unknown",
  47. "type": our_type,
  48. "version": detail.get("version") or entry.get("version"),
  49. "updated_time": entry.get("updated_time"),
  50. "base_id": detail.get("base_id"),
  51. "filament_id": detail.get("filament_id"),
  52. "setting": mask_preset(detail.get("setting") or {}),
  53. }
  54. def _orca_profile_record(entry: dict) -> dict:
  55. """One Orca profile as stored in the backup.
  56. ``content`` is kept whole rather than picked apart: it is the profile, the
  57. sync API hands it over inline, and Orca owns its shape. Narrowing it here
  58. would mean guessing which keys a future restore needs. A printer
  59. connection's credentials are the exception: shown as the placeholder.
  60. """
  61. return {
  62. "id": str(entry.get("id")) if entry.get("id") is not None else None,
  63. "name": entry.get("name"),
  64. "updated_time": entry.get("updated_time"),
  65. "created_time": entry.get("created_time"),
  66. "content": mask_preset(entry.get("content")),
  67. }
  68. # Schedule intervals in seconds
  69. SCHEDULE_INTERVALS = {
  70. "hourly": 3600,
  71. "daily": 86400,
  72. "weekly": 604800,
  73. }
  74. _PROVIDER_DISPLAY_NAMES = {
  75. "github": "GitHub",
  76. "gitlab": "GitLab",
  77. "gitea": "Gitea",
  78. "forgejo": "Forgejo",
  79. }
  80. def _without_refused_headers(result: dict) -> dict:
  81. """*result* with its texts masked where an HTTP client refusing a header
  82. quoted the value: the stored token, which the API never returns."""
  83. return {key: redact_refused_headers(value) if isinstance(value, str) else value for key, value in result.items()}
  84. class GitHubBackupService:
  85. """Service for backing up profiles to GitHub."""
  86. def __init__(self):
  87. self._scheduler_task: asyncio.Task | None = None
  88. self._check_interval = 60 # Check every minute for scheduled runs
  89. self._running_backup: bool = False
  90. self._backup_progress: str | None = None
  91. self._http_client: httpx.AsyncClient | None = None
  92. async def _get_client(self) -> httpx.AsyncClient:
  93. """Get or create HTTP client."""
  94. if self._http_client is None or self._http_client.is_closed:
  95. self._http_client = httpx.AsyncClient(timeout=60.0)
  96. return self._http_client
  97. async def start_scheduler(self):
  98. """Start the background scheduler loop."""
  99. if self._scheduler_task is not None:
  100. return
  101. logger.info("Starting GitHub backup scheduler")
  102. self._scheduler_task = asyncio.create_task(self._scheduler_loop())
  103. def stop_scheduler(self):
  104. """Stop the scheduler."""
  105. if self._scheduler_task:
  106. self._scheduler_task.cancel()
  107. self._scheduler_task = None
  108. logger.info("Stopped GitHub backup scheduler")
  109. async def _scheduler_loop(self):
  110. """Main scheduler loop - checks for due backups."""
  111. while True:
  112. try:
  113. await asyncio.sleep(self._check_interval)
  114. await self._check_scheduled_backups()
  115. except asyncio.CancelledError:
  116. break
  117. except Exception:
  118. logger.exception("Error in GitHub backup scheduler")
  119. await asyncio.sleep(60)
  120. async def _check_scheduled_backups(self):
  121. """Check if any scheduled backups are due."""
  122. async with async_session() as db:
  123. result = await db.execute(
  124. select(GitHubBackupConfig).where(
  125. GitHubBackupConfig.enabled == True, # noqa: E712
  126. GitHubBackupConfig.schedule_enabled == True, # noqa: E712
  127. )
  128. )
  129. configs = result.scalars().all()
  130. now = datetime.now(timezone.utc)
  131. for config in configs:
  132. # Handle both naive (from DB) and aware datetimes
  133. next_run = config.next_scheduled_run
  134. if next_run and next_run.tzinfo is None:
  135. next_run = next_run.replace(tzinfo=timezone.utc)
  136. if next_run and next_run <= now:
  137. logger.info("Running scheduled backup for config %s", config.id)
  138. await self.run_backup(config.id, trigger="scheduled")
  139. def calculate_next_run(self, schedule_type: str, from_time: datetime | None = None) -> datetime:
  140. """Calculate the next scheduled run time."""
  141. now = from_time or datetime.now(timezone.utc)
  142. interval = SCHEDULE_INTERVALS.get(schedule_type, SCHEDULE_INTERVALS["daily"])
  143. return now + timedelta(seconds=interval)
  144. async def test_connection(self, repo_url: str, token: str, provider: str = "github") -> dict:
  145. """Test connection and permissions for the given provider."""
  146. backend = get_provider_backend(provider)
  147. client = await self._get_client()
  148. return _without_refused_headers(await backend.test_connection(repo_url, token, client))
  149. async def run_backup(self, config_id: int, trigger: str = "manual") -> dict:
  150. """Run a backup operation.
  151. Args:
  152. config_id: ID of the backup configuration
  153. trigger: "manual" or "scheduled"
  154. Returns:
  155. dict with success, message, log_id, commit_sha, files_changed
  156. """
  157. # Everything from here to `self._running_backup = True` must stay
  158. # await-free. Both flags are plain bools and both callers are coroutines
  159. # on one event loop, so with no suspension point in between the loop
  160. # cannot run the restore service's mirror-image region (see
  161. # github_restore.run_restore) in the gap — whichever gets here first sets
  162. # its flag before the other can read it. Adding an `await` inside this
  163. # block reintroduces the check-then-set race and lets a backup and a
  164. # restore run at once.
  165. if self._running_backup:
  166. return {"success": False, "message": "A backup is already running", "log_id": None}
  167. # Imported locally to avoid a module-level import cycle — the restore
  168. # service imports this module's singleton to take the mirror-image lock.
  169. # A restore rewrites the same tables this collector reads and publishes
  170. # K-profiles to the same printers, so the two must not interleave.
  171. # (A local `import` of an already-loaded module is not a suspension
  172. # point, so it does not break the await-free rule above.)
  173. from backend.app.services.github_restore import github_restore_service
  174. if github_restore_service.is_running:
  175. return {
  176. "success": False,
  177. "message": "A restore is currently running. Wait for it to finish before backing up.",
  178. "log_id": None,
  179. }
  180. self._running_backup = True
  181. log_id = None
  182. try:
  183. async with async_session() as db:
  184. # Get config
  185. result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
  186. config = result.scalar_one_or_none()
  187. if not config:
  188. return {"success": False, "message": "Configuration not found", "log_id": None}
  189. if not config.enabled:
  190. return {"success": False, "message": "Backup is disabled", "log_id": None}
  191. # Defense in depth: re-verify the repo is private before each
  192. # push. The save endpoint already enforces this on every config
  193. # change, but a user can flip a repo from private to public in
  194. # GitHub's UI between configuration and the next scheduled run.
  195. test_result = await self.test_connection(
  196. config.repository_url, config.access_token, provider=config.provider
  197. )
  198. if not test_result.get("success") or test_result.get("is_private") is not True:
  199. visibility_note = (
  200. "the target repository is no longer private"
  201. if test_result.get("is_private") is False
  202. else "could not confirm the target repository is private"
  203. )
  204. abort_message = (
  205. f"Backup aborted: {visibility_note}. Bambuddy backups carry credentials "
  206. "and are refused for any non-private target. Make the repository private "
  207. "to resume scheduled backups."
  208. )
  209. log = GitHubBackupLog(
  210. config_id=config_id,
  211. status="failed",
  212. trigger=trigger,
  213. completed_at=datetime.now(timezone.utc),
  214. error_message=abort_message,
  215. )
  216. db.add(log)
  217. config.last_backup_at = datetime.now(timezone.utc)
  218. config.last_backup_status = "failed"
  219. config.last_backup_message = abort_message
  220. if config.schedule_enabled:
  221. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  222. await db.commit()
  223. await db.refresh(log)
  224. logger.warning(
  225. "Backup aborted for config %s: repo not private (is_private=%r, success=%r)",
  226. config_id,
  227. test_result.get("is_private"),
  228. test_result.get("success"),
  229. )
  230. return {
  231. "success": False,
  232. "message": abort_message,
  233. "log_id": log.id,
  234. }
  235. # Create log entry
  236. log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
  237. db.add(log)
  238. await db.commit()
  239. await db.refresh(log)
  240. log_id = log.id
  241. try:
  242. # Collect backup data
  243. self._backup_progress = "Collecting profiles..."
  244. backup_data = await self._collect_backup_data(db, config)
  245. if not backup_data:
  246. # No data to backup
  247. log.status = "skipped"
  248. log.completed_at = datetime.now(timezone.utc)
  249. log.error_message = "No data to backup"
  250. config.last_backup_at = datetime.now(timezone.utc)
  251. config.last_backup_status = "skipped"
  252. config.last_backup_message = "No data to backup"
  253. if config.schedule_enabled:
  254. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  255. await db.commit()
  256. return {
  257. "success": True,
  258. "message": "No data to backup",
  259. "log_id": log_id,
  260. "commit_sha": None,
  261. "files_changed": 0,
  262. }
  263. provider_name = _PROVIDER_DISPLAY_NAMES.get(config.provider, config.provider)
  264. self._backup_progress = f"Pushing to {provider_name}..."
  265. push_result = _without_refused_headers(await self._push_to_provider(config, backup_data))
  266. # Update log and config
  267. log.status = push_result["status"]
  268. log.completed_at = datetime.now(timezone.utc)
  269. log.commit_sha = push_result.get("commit_sha")
  270. log.files_changed = push_result.get("files_changed", 0)
  271. log.error_message = push_result.get("error")
  272. config.last_backup_at = datetime.now(timezone.utc)
  273. config.last_backup_status = push_result["status"]
  274. config.last_backup_message = push_result.get("message", "")
  275. config.last_backup_commit_sha = push_result.get("commit_sha")
  276. if config.schedule_enabled:
  277. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  278. await db.commit()
  279. return {
  280. "success": push_result["status"] in ("success", "skipped"),
  281. "message": push_result.get("message", "Backup completed"),
  282. "log_id": log_id,
  283. "commit_sha": push_result.get("commit_sha"),
  284. "files_changed": push_result.get("files_changed", 0),
  285. }
  286. except Exception as e:
  287. logger.exception("Backup failed")
  288. error = redact_refused_headers(str(e)) or ""
  289. log.status = "failed"
  290. log.completed_at = datetime.now(timezone.utc)
  291. log.error_message = error
  292. config.last_backup_at = datetime.now(timezone.utc)
  293. config.last_backup_status = "failed"
  294. config.last_backup_message = error
  295. if config.schedule_enabled:
  296. config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
  297. await db.commit()
  298. return {
  299. "success": False,
  300. "message": error,
  301. "log_id": log_id,
  302. "commit_sha": None,
  303. "files_changed": 0,
  304. }
  305. finally:
  306. self._running_backup = False
  307. self._backup_progress = None
  308. async def _collect_backup_data(self, db: AsyncSession, config: GitHubBackupConfig) -> dict:
  309. """Collect data to backup based on config settings.
  310. Returns dict with structure:
  311. {
  312. "backup_metadata.json": {...},
  313. "kprofiles/{serial}/{nozzle}.json": {...},
  314. "cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
  315. "cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
  316. "settings/app_settings.json": {...},
  317. }
  318. ``{account}`` is ``global`` when auth is disabled, otherwise
  319. ``user-{id}`` — one directory per connected cloud account (#2717).
  320. """
  321. files: dict[str, dict | list] = {}
  322. # Metadata file (no timestamps - git tracks file history)
  323. metadata = {
  324. "version": "1.0",
  325. "backup_type": "bambuddy_profiles",
  326. "contents": {
  327. "kprofiles": config.backup_kprofiles,
  328. "cloud_profiles": config.backup_cloud_profiles,
  329. "settings": config.backup_settings,
  330. "spools": config.backup_spools,
  331. "archives": config.backup_archives,
  332. },
  333. }
  334. files["backup_metadata.json"] = metadata
  335. # Collect K-profiles from all connected printers
  336. if config.backup_kprofiles:
  337. self._backup_progress = "Collecting K-profiles from printers..."
  338. await self._collect_kprofiles(db, files)
  339. # Collect cloud profiles. `contents.cloud_profiles` is corrected below
  340. # from what was configured to what was actually written — it claimed
  341. # `true` on every backup, including the ones that collected nothing
  342. # (#2717), which is exactly the signal a restore needs to be able to
  343. # trust.
  344. if config.backup_cloud_profiles:
  345. self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
  346. cloud_summary = await self._collect_cloud_profiles(db, files)
  347. collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
  348. metadata["contents"]["cloud_profiles"] = collected
  349. if collected:
  350. # Per-cloud, per-account counts, so a restore can tell an empty
  351. # account from one that failed to collect.
  352. metadata["cloud_profiles"] = cloud_summary
  353. # Collect app settings
  354. if config.backup_settings:
  355. self._backup_progress = "Collecting app settings..."
  356. await self._collect_settings(db, files)
  357. # Collect spool inventory
  358. if config.backup_spools:
  359. self._backup_progress = "Collecting spool inventory..."
  360. await self._collect_spools(db, files)
  361. # Collect print archives
  362. if config.backup_archives:
  363. self._backup_progress = "Collecting print archives..."
  364. await self._collect_archives(db, files)
  365. return files
  366. async def _collect_kprofiles(self, db: AsyncSession, files: dict):
  367. """Collect K-profiles from all connected printers."""
  368. result = await db.execute(select(Printer).where(Printer.is_active == True)) # noqa: E712
  369. printers = result.scalars().all()
  370. nozzle_diameters = ["0.2", "0.4", "0.6", "0.8"]
  371. for printer in printers:
  372. client = printer_manager.get_client(printer.id)
  373. if not client or not client.state.connected:
  374. continue
  375. serial = printer.serial_number
  376. printer_profiles = {}
  377. for nozzle in nozzle_diameters:
  378. try:
  379. profiles = await client.get_kprofiles(nozzle_diameter=nozzle)
  380. if profiles:
  381. profile_data = {
  382. "version": "1.0",
  383. "printer_name": printer.name,
  384. "printer_serial": serial,
  385. "nozzle_diameter": nozzle,
  386. "profiles": [
  387. {
  388. "slot_id": p.slot_id,
  389. "name": p.name,
  390. "k_value": p.k_value,
  391. "filament_id": p.filament_id,
  392. "nozzle_id": p.nozzle_id,
  393. "extruder_id": p.extruder_id,
  394. "setting_id": p.setting_id,
  395. "n_coef": p.n_coef,
  396. }
  397. for p in profiles
  398. ],
  399. }
  400. files[f"kprofiles/{serial}/{nozzle}.json"] = profile_data
  401. printer_profiles[nozzle] = len(profiles)
  402. except Exception as e:
  403. logger.warning("Failed to get K-profiles for printer %s nozzle %s: %s", serial, nozzle, e)
  404. if printer_profiles:
  405. logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
  406. async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
  407. """Collect slicer presets from every connected cloud account.
  408. Two clouds, and on an auth-enabled install any number of accounts in
  409. each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
  410. tokens on ``User.orca_cloud_token``, falling back to the global
  411. ``Settings`` table only when auth is disabled. The previous version
  412. asked for the auth-disabled store unconditionally, so it collected
  413. nothing at all on any install with auth on (#2717).
  414. Layout is one directory per cloud per account, both clouds grouped the
  415. same way so a restore reads them identically::
  416. cloud_profiles/bambu/user-3/{filament,printer,process}.json
  417. cloud_profiles/orca/user-3/{filament,printer,process}.json
  418. Accounts are keyed by Bambuddy user id (``global`` when auth is off),
  419. never by email — a backup repository can be public.
  420. Returns a per-cloud summary for ``backup_metadata.json`` so the
  421. metadata records what was actually collected rather than what was
  422. merely enabled.
  423. """
  424. summary: dict = {"bambu": {}, "orca": {}}
  425. bambu_accounts, orca_accounts = await self.cloud_accounts(db)
  426. if not bambu_accounts and not orca_accounts:
  427. # Enabled but nothing to collect. Deliberately a warning: the INFO
  428. # line this replaces read as a successful collection of nothing,
  429. # which is how #2717 went unnoticed through every backup.
  430. logger.warning(
  431. "Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
  432. "account is connected — nothing to collect."
  433. )
  434. return summary
  435. for account_key, user in bambu_accounts:
  436. try:
  437. counts = await self._collect_bambu_profiles(db, files, account_key, user)
  438. except Exception:
  439. logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
  440. continue
  441. if counts:
  442. summary["bambu"][account_key] = counts
  443. for account_key, user in orca_accounts:
  444. try:
  445. counts = await self._collect_orca_profiles(db, files, account_key, user)
  446. except Exception:
  447. logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
  448. continue
  449. if counts:
  450. summary["orca"][account_key] = counts
  451. if not summary["bambu"] and not summary["orca"]:
  452. logger.warning(
  453. "Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
  454. "but no presets were collected — see the per-account warnings above.",
  455. len(bambu_accounts),
  456. len(orca_accounts),
  457. )
  458. else:
  459. logger.info("Collected cloud profiles: %s", summary)
  460. return summary
  461. async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
  462. """Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
  463. With auth enabled every user holds their own credentials, so a backup
  464. that only looked at the global store saw none of them. With auth
  465. disabled there is a single global row and no ``User`` at all, which is
  466. what ``user=None`` means to both clouds' credential loaders.
  467. Both stores are read regardless: a ``Settings`` row survives enabling
  468. auth later, and dropping it silently would lose that account's presets.
  469. """
  470. from backend.app.api.routes.orca_cloud import _load_credentials
  471. from backend.app.services.bambu_cloud_credentials import get_stored_token
  472. bambu: list = []
  473. orca: list = []
  474. global_token, _email, _region = await get_stored_token(db, None)
  475. if global_token:
  476. bambu.append(("global", None))
  477. global_orca = await _load_credentials(db, None)
  478. if global_orca.token:
  479. orca.append(("global", None))
  480. result = await db.execute(
  481. select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
  482. )
  483. for user in result.scalars().all():
  484. if user.cloud_token:
  485. bambu.append((f"user-{user.id}", user))
  486. if user.orca_cloud_token:
  487. orca.append((f"user-{user.id}", user))
  488. return bambu, orca
  489. async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
  490. """Collect one Bambu Cloud account's custom presets, with their payloads.
  491. The listing endpoint is keyed by preset type, each holding ``private``
  492. and ``public`` lists — there is no flat ``setting`` array, and the
  493. entries carry no ``type`` of their own, which is why the type comes
  494. from the outer key here exactly as it does in ``routes/cloud.py``.
  495. Bambu calls process presets ``print``.
  496. ``public`` is skipped: those are Bambu's own bundled catalogue, the
  497. same hundreds of entries for every user, re-downloadable at any time
  498. and not recreatable under your account anyway. Backing them up would
  499. churn the repository on every run for nothing.
  500. Each private preset then costs one ``get_setting_detail`` call, because
  501. the listing carries only metadata. Without ``base_id`` and ``setting``
  502. the backup is a list of names, not something a restore can rebuild
  503. from. Bounded by the number of *custom* presets, and the backup already
  504. makes a round-trip per printer for K-profiles.
  505. """
  506. from backend.app.api.routes.cloud import build_authenticated_cloud
  507. cloud = await build_authenticated_cloud(db, user=user)
  508. if cloud is None or not cloud.is_authenticated:
  509. logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
  510. return {}
  511. counts: dict = {}
  512. try:
  513. settings = await cloud.get_slicer_settings()
  514. if not isinstance(settings, dict) or not settings:
  515. logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
  516. return {}
  517. failed = 0
  518. for api_key, our_type in _BAMBU_PRESET_TYPES.items():
  519. type_data = settings.get(api_key)
  520. if not isinstance(type_data, dict):
  521. continue
  522. private = type_data.get("private")
  523. if not isinstance(private, list) or not private:
  524. continue
  525. profiles = []
  526. for entry in private:
  527. setting_id = entry.get("setting_id") or entry.get("id")
  528. if not setting_id:
  529. continue
  530. try:
  531. detail = await cloud.get_setting_detail(str(setting_id))
  532. except Exception as e:
  533. # One unreadable preset must not cost the rest of the
  534. # account, but it must not vanish quietly either.
  535. failed += 1
  536. logger.warning(
  537. "Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
  538. setting_id,
  539. entry.get("name", "unnamed"),
  540. account_key,
  541. e,
  542. )
  543. continue
  544. profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
  545. if profiles:
  546. files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
  547. "version": "2.0",
  548. "cloud": "bambu",
  549. "type": our_type,
  550. "profiles": profiles,
  551. }
  552. counts[our_type] = len(profiles)
  553. if failed:
  554. counts["failed"] = failed
  555. return counts
  556. finally:
  557. await cloud.close()
  558. async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
  559. """Collect one Orca Cloud account's profiles, grouped the same three ways.
  560. Cheaper than Bambu: the sync-pull listing already carries each
  561. profile's full ``content``, so there is no per-profile fetch.
  562. The type lives at ``content.type`` and is mapped through the same
  563. ``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
  564. exactly as the UI does. Where that route *drops* a profile whose type
  565. it can't map, this writes it to ``other.json`` instead — a backup that
  566. silently omits a profile because Orca added a type is the same class of
  567. bug as #2717 itself.
  568. Uses the route layer's ``_build_authenticated_service`` rather than
  569. re-implementing the refresh: the Orca refresh token is single-use and
  570. rotating, and that helper already persists the new pair atomically
  571. before returning.
  572. Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
  573. account instead of disconnecting it. A backup is an observer; it should
  574. not change anyone's sign-in state on a schedule, least of all on a
  575. rejection reason Orca does not disambiguate. The next time the user
  576. opens the Orca Profiles page that route clears the dead pairing anyway,
  577. with the user present to pair again.
  578. """
  579. from fastapi import HTTPException
  580. from backend.app.api.routes.orca_cloud import (
  581. _ORCA_TYPE_TO_BAMBU,
  582. _build_authenticated_service,
  583. )
  584. try:
  585. svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
  586. except HTTPException as e:
  587. # Either way the stored credentials are untouched and this account
  588. # is skipped, not disconnected — but the two need different advice.
  589. # A rejected refresh will not fix itself and needs the user to pair
  590. # again; an unreachable Orca is very likely gone by the next run.
  591. if e.status_code == 401:
  592. logger.warning(
  593. "Orca Cloud rejected the stored session for %s, so its profiles are not in this "
  594. "backup. Later runs will skip it too until the account is paired again under "
  595. "Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
  596. "cleared. Cause: %s",
  597. account_key,
  598. e.detail,
  599. )
  600. else:
  601. logger.warning(
  602. "Orca Cloud unreachable for %s, skipping its profiles this run: %s",
  603. account_key,
  604. e.detail,
  605. )
  606. return {}
  607. except Exception as e:
  608. logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
  609. return {}
  610. counts: dict = {}
  611. try:
  612. raw_profiles = await svc.list_profiles()
  613. grouped: dict[str, list] = {}
  614. unknown_types: dict[str, int] = {}
  615. for entry in raw_profiles:
  616. if not isinstance(entry, dict):
  617. continue
  618. content = entry.get("content")
  619. raw_type = content.get("type") if isinstance(content, dict) else None
  620. our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
  621. if our_type is None:
  622. unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
  623. unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
  624. )
  625. our_type = "other"
  626. grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
  627. for our_type, profiles in grouped.items():
  628. files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
  629. "version": "2.0",
  630. "cloud": "orca",
  631. "type": our_type,
  632. "profiles": profiles,
  633. }
  634. counts[our_type] = len(profiles)
  635. if unknown_types:
  636. logger.warning(
  637. "Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — "
  638. "backed up to other.json rather than dropped.",
  639. sum(unknown_types.values()),
  640. account_key,
  641. unknown_types,
  642. )
  643. return counts
  644. finally:
  645. await svc.close()
  646. async def _collect_settings(self, db: AsyncSession, files: dict):
  647. """Collect app settings."""
  648. result = await db.execute(select(Settings))
  649. settings = result.scalars().all()
  650. # A credential is written as the placeholder when set (blank counts as
  651. # unset, as it does for the restore), so the restore can still tell
  652. # whether a switch had one. A URL with a login in it is
  653. # left out: masked, it would be restored over a working one.
  654. # Anything else a restore would not write is left out: keys earlier
  655. # versions left behind (some hold a credential under a name no rule
  656. # recognises), runtime state and Bambuddy's own flags.
  657. settings_data = {}
  658. for s in settings:
  659. if is_secret_setting_key(s.key):
  660. settings_data[s.key] = SECRET_PLACEHOLDER if (s.value or "").strip() else s.value
  661. elif is_restorable_setting_key(s.key) and not URL_CREDENTIALS_PATTERN.search(s.value or ""):
  662. settings_data[s.key] = s.value
  663. files["settings/app_settings.json"] = {
  664. "version": "1.0",
  665. "settings": settings_data,
  666. }
  667. async def _collect_spools(self, db: AsyncSession, files: dict):
  668. """Collect spool inventory data."""
  669. result = await db.execute(select(Spool))
  670. spools = result.scalars().all()
  671. if spools:
  672. self._collect_spool_list(spools, files)
  673. # Collected without built-in spools too: in Spoolman mode the usage
  674. # history names Spoolman's spools, and there are no built-in ones (#3407).
  675. await self._collect_spool_usage(db, files)
  676. def _collect_spool_list(self, spools, files: dict) -> None:
  677. spool_list = []
  678. for s in spools:
  679. spool_data = {
  680. "id": s.id,
  681. "material": s.material,
  682. "subtype": s.subtype,
  683. "color_name": s.color_name,
  684. "rgba": s.rgba,
  685. "brand": s.brand,
  686. "label_weight": s.label_weight,
  687. "core_weight": s.core_weight,
  688. "weight_used": s.weight_used,
  689. # The "Reset Total Consumed" point (WU-7).
  690. "weight_used_baseline": s.weight_used_baseline,
  691. "weight_locked": s.weight_locked,
  692. "slicer_filament": s.slicer_filament,
  693. "slicer_filament_name": s.slicer_filament_name,
  694. "nozzle_temp_min": s.nozzle_temp_min,
  695. "nozzle_temp_max": s.nozzle_temp_max,
  696. "note": s.note,
  697. "cost_per_kg": s.cost_per_kg,
  698. # The user's own bookkeeping on the spool: purchasing number
  699. # (#2870), category and low-stock override (#729), free-text
  700. # storage. All four were missing from this whitelist, so a
  701. # restore silently dropped them. `location_id` stays out —
  702. # the locations table itself is not in the backup, so the ID
  703. # would point at whatever happens to own it on the target.
  704. "material_number": s.material_number,
  705. "category": s.category,
  706. "low_stock_threshold_pct": s.low_stock_threshold_pct,
  707. "storage_location": s.storage_location,
  708. # When it was last printed with (LU-4): without it, every spool
  709. # read as never used after a restore.
  710. "last_used": str(s.last_used) if s.last_used else None,
  711. # Last drying (#2863).
  712. "last_dried_at": str(s.last_dried_at) if s.last_dried_at else None,
  713. "last_dried_temp": s.last_dried_temp,
  714. "last_dried_hours": s.last_dried_hours,
  715. "tag_uid": s.tag_uid,
  716. "tray_uuid": s.tray_uuid,
  717. "data_origin": s.data_origin,
  718. "tag_type": s.tag_type,
  719. "archived_at": str(s.archived_at) if s.archived_at else None,
  720. "created_at": str(s.created_at) if s.created_at else None,
  721. }
  722. spool_list.append(spool_data)
  723. files["spools/inventory.json"] = {
  724. "version": "1.0",
  725. "spools": spool_list,
  726. }
  727. logger.info("Collected %d spools", len(spool_list))
  728. async def _collect_spool_usage(self, db: AsyncSession, files: dict) -> None:
  729. usage_result = await db.execute(select(SpoolUsageHistory))
  730. usages = usage_result.scalars().all()
  731. if usages:
  732. usage_list = []
  733. for u in usages:
  734. usage_list.append(
  735. {
  736. "id": u.id,
  737. "spool_id": u.spool_id,
  738. "spoolman_spool_id": u.spoolman_spool_id,
  739. "printer_id": u.printer_id,
  740. "print_name": u.print_name,
  741. "archive_id": u.archive_id,
  742. "weight_used": u.weight_used,
  743. "percent_used": u.percent_used,
  744. "status": u.status,
  745. "cost": u.cost,
  746. "created_at": str(u.created_at) if u.created_at else None,
  747. }
  748. )
  749. files["spools/usage_history.json"] = {
  750. "version": "1.0",
  751. "usage_history": usage_list,
  752. }
  753. logger.info("Collected %d usage records", len(usages))
  754. async def _collect_archives(self, db: AsyncSession, files: dict):
  755. """Collect print archive metadata (no binary files)."""
  756. result = await db.execute(select(PrintArchive))
  757. archives = result.scalars().all()
  758. # Its prints and merges, written whenever archives are: a push keeps
  759. # the files a backup leaves out, so skipping an empty list would leave
  760. # an earlier backup's beside these archives.
  761. await self._collect_print_runs(db, files)
  762. await self._collect_archive_aliases(db, files)
  763. if not archives:
  764. return
  765. # The natural key for an owner. created_by_id alone is only meaningful on
  766. # the instance that wrote it: restoring onto a rebuilt instance — this
  767. # feature's main use case — renumbers the users table, so a live id can
  768. # land on a different person. username is unique on users, so the restore
  769. # can resolve on it and treat a rename as unknown rather than guess.
  770. # One query for the map; archives outnumber users by orders of magnitude.
  771. user_names = dict((await db.execute(select(User.id, User.username))).all())
  772. archive_list = []
  773. for a in archives:
  774. archive_data = {
  775. "id": a.id,
  776. "printer_id": a.printer_id,
  777. "project_id": a.project_id,
  778. "filename": a.filename,
  779. "file_size": a.file_size,
  780. "content_hash": a.content_hash,
  781. "print_name": a.print_name,
  782. "print_time_seconds": a.print_time_seconds,
  783. "filament_used_grams": a.filament_used_grams,
  784. "filament_type": a.filament_type,
  785. "filament_color": a.filament_color,
  786. "layer_height": a.layer_height,
  787. "total_layers": a.total_layers,
  788. "nozzle_diameter": a.nozzle_diameter,
  789. "bed_temperature": a.bed_temperature,
  790. "nozzle_temperature": a.nozzle_temperature,
  791. "sliced_for_model": a.sliced_for_model,
  792. "status": a.status,
  793. "started_at": str(a.started_at) if a.started_at else None,
  794. "completed_at": str(a.completed_at) if a.completed_at else None,
  795. "makerworld_url": a.makerworld_url,
  796. "designer": a.designer,
  797. "external_url": a.external_url,
  798. "is_favorite": a.is_favorite,
  799. "tags": a.tags,
  800. "notes": a.notes,
  801. "cost": a.cost,
  802. "failure_reason": a.failure_reason,
  803. "quantity": a.quantity,
  804. "energy_kwh": a.energy_kwh,
  805. "energy_cost": a.energy_cost,
  806. "wear_cost": a.wear_cost,
  807. "created_at": str(a.created_at) if a.created_at else None,
  808. # Soft-deleted archives are collected too — their row is kept on
  809. # purpose so the stats endpoint keeps counting their filament and
  810. # energy (see archive_service.soft_delete_archive). Recording
  811. # deleted_at is what lets a restore put them back the way they
  812. # were instead of resurrecting them as visible archives.
  813. "deleted_at": str(a.deleted_at) if a.deleted_at else None,
  814. # Who owns the archive, for the same reason deleted_at is here:
  815. # it is not decoration, it is what the access check runs on.
  816. # _ensure_archive_visible (api/routes/archives.py) fails closed on
  817. # a NULL created_by_id and the list paths filter on it, so a
  818. # restored row without it is invisible to everyone but an admin —
  819. # while the restore reports it restored.
  820. "created_by_id": a.created_by_id,
  821. # Preferred over the id on restore; the id stays as the fallback
  822. # for an owner whose row has since gone. Null when the archive
  823. # has no owner, or when it points at a user row that no longer
  824. # exists locally — the same "absent is not null" rule the restore
  825. # applies, so a backup can't claim an owner it cannot name.
  826. "created_by_username": user_names.get(a.created_by_id),
  827. }
  828. archive_list.append(archive_data)
  829. files["archives/print_history.json"] = {
  830. "version": "1.0",
  831. "archives": archive_list,
  832. }
  833. logger.info("Collected %d print archives", len(archive_list))
  834. async def _collect_print_runs(self, db: AsyncSession, files: dict) -> None:
  835. """Each print of an archive (archive redesign P3): statistics and the
  836. print log count these, not archives.
  837. Left out: the one-tap confirm token (a credential), photo, timelapse
  838. and thumbnail paths (their files are not in a Git backup), the energy
  839. meter reading at the start (only needed while it prints), and the
  840. wallet, cost centre and queue links, none of which are backed up.
  841. """
  842. runs = (await db.execute(select(PrintLogEntry).execution_options(**{INCLUDE_OPEN_RUNS: True}))).scalars().all()
  843. user_names = dict((await db.execute(select(User.id, User.username))).all())
  844. files["archives/print_runs.json"] = {
  845. "version": "1.0",
  846. "runs": [
  847. {
  848. "id": r.id,
  849. "archive_id": r.archive_id,
  850. "print_name": r.print_name,
  851. "plate_id": r.plate_id,
  852. "printer_id": r.printer_id,
  853. "printer_name": r.printer_name,
  854. "status": r.status,
  855. "started_at": str(r.started_at) if r.started_at else None,
  856. "completed_at": str(r.completed_at) if r.completed_at else None,
  857. "duration_seconds": r.duration_seconds,
  858. "duration_unmeasured": r.duration_unmeasured,
  859. "ended_offline": r.ended_offline,
  860. "filament_type": r.filament_type,
  861. "filament_color": r.filament_color,
  862. "filament_used_grams": r.filament_used_grams,
  863. "cost": r.cost,
  864. "energy_kwh": r.energy_kwh,
  865. "energy_cost": r.energy_cost,
  866. "wear_cost": r.wear_cost,
  867. "failure_reason": r.failure_reason,
  868. "user_verdict": r.user_verdict,
  869. "user_verdict_source": r.user_verdict_source,
  870. "user_verdict_at": str(r.user_verdict_at) if r.user_verdict_at else None,
  871. "quantity": r.quantity,
  872. "notes": r.notes,
  873. # Who started it, as the print log shows it.
  874. "created_by_username": r.created_by_username,
  875. # Its owner, by name as for archives (ids renumber on a
  876. # rebuilt instance), and only one that still exists: a
  877. # deleted user's name may be someone else's on restore.
  878. "owner_username": user_names.get(r.created_by_id) if r.created_by_id is not None else None,
  879. "created_at": str(r.created_at) if r.created_at else None,
  880. }
  881. for r in runs
  882. ],
  883. }
  884. logger.info("Collected %d prints", len(runs))
  885. async def _collect_archive_aliases(self, db: AsyncSession, files: dict) -> None:
  886. """Which archive went into which (archive redesign P2), so a restore
  887. follows a merged archive to the one it went into."""
  888. aliases = (await db.execute(select(ArchiveAlias.merged_archive_id, ArchiveAlias.archive_id))).all()
  889. files["archives/aliases.json"] = {
  890. "version": "1.0",
  891. "aliases": [{"merged_archive_id": merged, "archive_id": into} for merged, into in aliases],
  892. }
  893. async def _push_to_provider(self, config: GitHubBackupConfig, files: dict) -> dict:
  894. """Push files to the configured Git provider."""
  895. backend = get_provider_backend(config.provider)
  896. client = await self._get_client()
  897. return await backend.push_files(
  898. repo_url=config.repository_url,
  899. token=config.access_token,
  900. branch=config.branch,
  901. files=files,
  902. client=client,
  903. )
  904. @property
  905. def is_running(self) -> bool:
  906. """Check if a backup is currently running."""
  907. return self._running_backup
  908. @property
  909. def progress(self) -> str | None:
  910. """Get current backup progress message."""
  911. return self._backup_progress
  912. # Singleton instance
  913. github_backup_service = GitHubBackupService()