support.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. """Support endpoints for debug logging and support bundle generation."""
  2. import io
  3. import json
  4. import logging
  5. import os
  6. import platform
  7. import zipfile
  8. from datetime import datetime
  9. from fastapi import APIRouter, HTTPException
  10. from fastapi.responses import StreamingResponse
  11. from pydantic import BaseModel
  12. from sqlalchemy import func, select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.core.config import APP_VERSION, settings
  15. from backend.app.core.database import async_session
  16. from backend.app.models.archive import PrintArchive
  17. from backend.app.models.filament import Filament
  18. from backend.app.models.printer import Printer
  19. from backend.app.models.project import Project
  20. from backend.app.models.settings import Settings
  21. from backend.app.models.smart_plug import SmartPlug
  22. router = APIRouter(prefix="/support", tags=["support"])
  23. logger = logging.getLogger(__name__)
  24. # In-memory state for debug logging (persisted to settings DB)
  25. _debug_logging_enabled = False
  26. _debug_logging_enabled_at: datetime | None = None
  27. class DebugLoggingState(BaseModel):
  28. enabled: bool
  29. enabled_at: str | None = None
  30. duration_seconds: int | None = None
  31. class DebugLoggingToggle(BaseModel):
  32. enabled: bool
  33. async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
  34. """Get debug logging state from database."""
  35. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  36. enabled_setting = result.scalar_one_or_none()
  37. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  38. enabled_at_setting = result.scalar_one_or_none()
  39. enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
  40. enabled_at = None
  41. if enabled_at_setting and enabled_at_setting.value:
  42. try:
  43. enabled_at = datetime.fromisoformat(enabled_at_setting.value)
  44. except ValueError:
  45. pass
  46. return enabled, enabled_at
  47. async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
  48. """Set debug logging state in database."""
  49. # Update or create enabled setting
  50. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  51. setting = result.scalar_one_or_none()
  52. if setting:
  53. setting.value = str(enabled).lower()
  54. else:
  55. db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
  56. # Update enabled_at timestamp
  57. enabled_at = datetime.now() if enabled else None
  58. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  59. at_setting = result.scalar_one_or_none()
  60. if at_setting:
  61. at_setting.value = enabled_at.isoformat() if enabled_at else ""
  62. else:
  63. db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
  64. await db.commit()
  65. return enabled_at
  66. def _apply_log_level(debug: bool):
  67. """Apply log level change to root logger."""
  68. root_logger = logging.getLogger()
  69. new_level = logging.DEBUG if debug else logging.INFO
  70. root_logger.setLevel(new_level)
  71. for handler in root_logger.handlers:
  72. handler.setLevel(new_level)
  73. # Also adjust third-party loggers
  74. if debug:
  75. logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
  76. logging.getLogger("httpcore").setLevel(logging.DEBUG)
  77. logging.getLogger("httpx").setLevel(logging.DEBUG)
  78. else:
  79. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  80. logging.getLogger("httpcore").setLevel(logging.WARNING)
  81. logging.getLogger("httpx").setLevel(logging.WARNING)
  82. logger.info(f"Log level changed to {'DEBUG' if debug else 'INFO'}")
  83. @router.get("/debug-logging", response_model=DebugLoggingState)
  84. async def get_debug_logging_state():
  85. """Get current debug logging state."""
  86. global _debug_logging_enabled, _debug_logging_enabled_at
  87. async with async_session() as db:
  88. enabled, enabled_at = await _get_debug_setting(db)
  89. _debug_logging_enabled = enabled
  90. _debug_logging_enabled_at = enabled_at
  91. duration = None
  92. if enabled and enabled_at:
  93. duration = int((datetime.now() - enabled_at).total_seconds())
  94. return DebugLoggingState(
  95. enabled=enabled,
  96. enabled_at=enabled_at.isoformat() if enabled_at else None,
  97. duration_seconds=duration,
  98. )
  99. @router.post("/debug-logging", response_model=DebugLoggingState)
  100. async def toggle_debug_logging(toggle: DebugLoggingToggle):
  101. """Enable or disable debug logging."""
  102. global _debug_logging_enabled, _debug_logging_enabled_at
  103. async with async_session() as db:
  104. enabled_at = await _set_debug_setting(db, toggle.enabled)
  105. _debug_logging_enabled = toggle.enabled
  106. _debug_logging_enabled_at = enabled_at
  107. _apply_log_level(toggle.enabled)
  108. duration = None
  109. if toggle.enabled and enabled_at:
  110. duration = int((datetime.now() - enabled_at).total_seconds())
  111. return DebugLoggingState(
  112. enabled=toggle.enabled,
  113. enabled_at=enabled_at.isoformat() if enabled_at else None,
  114. duration_seconds=duration,
  115. )
  116. def _sanitize_path(path: str) -> str:
  117. """Remove username from paths for privacy."""
  118. import re
  119. # Replace /home/username/ or /Users/username/ with /home/[user]/
  120. path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
  121. path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
  122. # Replace /opt/username/ patterns
  123. path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
  124. return path
  125. async def _collect_support_info() -> dict:
  126. """Collect all support information."""
  127. info = {
  128. "generated_at": datetime.now().isoformat(),
  129. "app": {
  130. "version": APP_VERSION,
  131. "debug_mode": settings.debug,
  132. },
  133. "system": {
  134. "platform": platform.system(),
  135. "platform_release": platform.release(),
  136. "platform_version": platform.version(),
  137. "architecture": platform.machine(),
  138. "python_version": platform.python_version(),
  139. },
  140. "environment": {
  141. "docker": os.path.exists("/.dockerenv"),
  142. "data_dir": _sanitize_path(str(settings.base_dir)),
  143. "log_dir": _sanitize_path(str(settings.log_dir)),
  144. },
  145. "database": {},
  146. "printers": [],
  147. "settings": {},
  148. }
  149. async with async_session() as db:
  150. # Database stats
  151. result = await db.execute(select(func.count(PrintArchive.id)))
  152. info["database"]["archives_total"] = result.scalar() or 0
  153. result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  154. info["database"]["archives_completed"] = result.scalar() or 0
  155. result = await db.execute(select(func.count(Printer.id)))
  156. info["database"]["printers_total"] = result.scalar() or 0
  157. result = await db.execute(select(func.count(Filament.id)))
  158. info["database"]["filaments_total"] = result.scalar() or 0
  159. result = await db.execute(select(func.count(Project.id)))
  160. info["database"]["projects_total"] = result.scalar() or 0
  161. result = await db.execute(select(func.count(SmartPlug.id)))
  162. info["database"]["smart_plugs_total"] = result.scalar() or 0
  163. # Printer info (anonymized - just models and connection status)
  164. result = await db.execute(select(Printer))
  165. printers = result.scalars().all()
  166. for i, printer in enumerate(printers):
  167. info["printers"].append(
  168. {
  169. "index": i + 1,
  170. "model": printer.model or "Unknown",
  171. "nozzle_count": printer.nozzle_count,
  172. }
  173. )
  174. # Non-sensitive settings
  175. result = await db.execute(select(Settings))
  176. all_settings = result.scalars().all()
  177. sensitive_keys = {
  178. "access_code",
  179. "password",
  180. "token",
  181. "secret",
  182. "api_key",
  183. "installation_id",
  184. "cloud_token",
  185. "mqtt_password",
  186. "email",
  187. "vapid",
  188. "private_key",
  189. "public_key",
  190. "webhook",
  191. "url",
  192. "config", # URLs may contain IPs, configs may have embedded secrets
  193. }
  194. for s in all_settings:
  195. # Skip sensitive settings
  196. if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
  197. continue
  198. info["settings"][s.key] = s.value
  199. return info
  200. def _sanitize_log_content(content: str) -> str:
  201. """Remove sensitive data from log content."""
  202. import re
  203. # Replace IP addresses with [IP]
  204. content = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", content)
  205. # Replace email addresses
  206. content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
  207. # Replace paths with usernames
  208. content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
  209. content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
  210. content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
  211. return content
  212. def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
  213. """Get log file content, limited to max_bytes from the end."""
  214. log_file = settings.log_dir / "bambuddy.log"
  215. if not log_file.exists():
  216. return b"Log file not found"
  217. file_size = log_file.stat().st_size
  218. if file_size <= max_bytes:
  219. content = log_file.read_text(encoding="utf-8", errors="replace")
  220. else:
  221. # Read last max_bytes
  222. with open(log_file, "rb") as f:
  223. f.seek(file_size - max_bytes)
  224. # Skip partial line at start
  225. f.readline()
  226. content = f.read().decode("utf-8", errors="replace")
  227. # Sanitize sensitive data
  228. content = _sanitize_log_content(content)
  229. return content.encode("utf-8")
  230. @router.get("/bundle")
  231. async def generate_support_bundle():
  232. """Generate a support bundle ZIP file for issue reporting."""
  233. global _debug_logging_enabled, _debug_logging_enabled_at
  234. # Check if debug logging is enabled
  235. async with async_session() as db:
  236. enabled, enabled_at = await _get_debug_setting(db)
  237. _debug_logging_enabled = enabled
  238. _debug_logging_enabled_at = enabled_at
  239. if not enabled:
  240. raise HTTPException(
  241. status_code=400,
  242. detail="Debug logging must be enabled before generating a support bundle. "
  243. "Please enable debug logging, reproduce the issue, then generate the bundle.",
  244. )
  245. # Collect support info
  246. support_info = await _collect_support_info()
  247. # Create ZIP in memory
  248. zip_buffer = io.BytesIO()
  249. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  250. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  251. # Add support info JSON
  252. zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
  253. # Add log file
  254. log_content = _get_log_content()
  255. zf.writestr("bambuddy.log", log_content)
  256. zip_buffer.seek(0)
  257. filename = f"bambuddy-support-{timestamp}.zip"
  258. logger.info(f"Generated support bundle: {filename}")
  259. return StreamingResponse(
  260. zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
  261. )
  262. async def init_debug_logging():
  263. """Initialize debug logging state from database on startup."""
  264. global _debug_logging_enabled, _debug_logging_enabled_at
  265. try:
  266. async with async_session() as db:
  267. enabled, enabled_at = await _get_debug_setting(db)
  268. _debug_logging_enabled = enabled
  269. _debug_logging_enabled_at = enabled_at
  270. if enabled:
  271. _apply_log_level(True)
  272. logger.info("Debug logging restored from previous session")
  273. except Exception as e:
  274. logger.warning(f"Could not restore debug logging state: {e}")