virtual_printers.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. import logging
  2. import os
  3. from pathlib import Path
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from fastapi.responses import JSONResponse
  6. from pydantic import BaseModel
  7. from sqlalchemy import select, update
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.core.auth import (
  10. ApiKeyActor,
  11. RequestActor,
  12. RequestPrinterScope,
  13. RequirePermissionIfAuthEnabled,
  14. get_current_active_user,
  15. )
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.printer_scope import PrinterScope
  19. from backend.app.models.user import User
  20. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  21. from backend.app.schemas.virtual_printer import VPDiagnosticResult
  22. # Imported at module scope so tests can patch
  23. # backend.app.api.routes.virtual_printers.tailscale_service.
  24. from backend.app.services.virtual_printer.tailscale import tailscale_service
  25. logger = logging.getLogger(__name__)
  26. router = APIRouter(prefix="/virtual-printers", tags=["virtual-printers"])
  27. class TailscaleStatusResponse(BaseModel):
  28. available: bool
  29. fqdn: str
  30. hostname: str
  31. tailnet_name: str
  32. tailscale_ips: list[str]
  33. error: str | None
  34. class VirtualPrinterCreate(BaseModel):
  35. name: str = "Bambuddy"
  36. enabled: bool = False
  37. mode: str = "library"
  38. model: str | None = None
  39. access_code: str | None = None
  40. target_printer_id: int | None = None
  41. library_folder_id: int | None = None
  42. queue_remove_after_print: bool = False
  43. auto_dispatch: bool = True
  44. queue_force_color_match: bool = False
  45. save_ams_mapping: bool = False
  46. gcode_injection: bool = False
  47. bind_ip: str | None = None
  48. remote_interface_ip: str | None = None
  49. class VirtualPrinterUpdate(IgnoresNullForRequiredColumns):
  50. __orm_table__ = "virtual_printers"
  51. name: str | None = None
  52. enabled: bool | None = None
  53. mode: str | None = None
  54. model: str | None = None
  55. access_code: str | None = None
  56. target_printer_id: int | None = None
  57. library_folder_id: int | None = None
  58. queue_remove_after_print: bool | None = None
  59. auto_dispatch: bool | None = None
  60. queue_force_color_match: bool | None = None
  61. save_ams_mapping: bool | None = None
  62. gcode_injection: bool | None = None
  63. bind_ip: str | None = None
  64. remote_interface_ip: str | None = None
  65. tailscale_disabled: bool | None = None
  66. def _resolve_printer_model(printer_model: str | None, serial: str | None = None) -> str | None:
  67. """Map a printer's model (display name or SSDP code) to a valid VP SSDP model code.
  68. Printers store display names like 'X1C' while VPs need SSDP codes like
  69. 'BL-P001'. ``serial`` tells apart the codes one name covers (H2C: O1C or
  70. O1C2); see ``manager.model_code_for_printer``.
  71. """
  72. from backend.app.services.virtual_printer.manager import model_code_for_printer
  73. return model_code_for_printer(printer_model, serial)
  74. async def _check_upload_folder(db: AsyncSession, folder_id: int, user) -> JSONResponse | None:
  75. """None when the virtual printer may store uploads in ``folder_id``.
  76. The user setting it must be able to add files there themselves (#3201),
  77. and it must be an internal folder: an external mount refuses a second file
  78. of the same name, and slicer names repeat.
  79. """
  80. from backend.app.services.library_folder_access import get_writable_folder
  81. folder = await get_writable_folder(db, folder_id, user)
  82. if folder.is_external:
  83. return JSONResponse(
  84. status_code=400,
  85. content={"detail": "Virtual printer uploads can't go to an external folder"},
  86. )
  87. return None
  88. def _target_missing(target_printer_id: int) -> JSONResponse:
  89. return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
  90. def _vp_missing() -> JSONResponse:
  91. return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
  92. async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
  93. """Convert VirtualPrinter model to response dict.
  94. In proxy mode the surfaced serial is the target printer's actual serial
  95. (what the bridge advertises over SSDP / what slicers see), not the
  96. self-generated suffix. Archive / queue / review keep the self-generated
  97. serial since those modes never speak the target's identity.
  98. """
  99. from backend.app.models.printer import Printer
  100. from backend.app.models.virtual_printer import VP_MODE_PROXY
  101. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS
  102. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL, _get_serial_for_model
  103. model_code = vp.model or DEFAULT_VIRTUAL_PRINTER_MODEL
  104. serial = _get_serial_for_model(model_code, vp.serial_suffix)
  105. if vp.mode == VP_MODE_PROXY and vp.target_printer_id:
  106. result = await db.execute(select(Printer.serial_number).where(Printer.id == vp.target_printer_id))
  107. target_serial = result.scalar_one_or_none()
  108. if target_serial:
  109. serial = target_serial
  110. return {
  111. "id": vp.id,
  112. "name": vp.name,
  113. "enabled": vp.enabled,
  114. "mode": vp.mode,
  115. "model": model_code,
  116. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  117. "access_code_set": bool(vp.access_code),
  118. "serial": serial,
  119. "target_printer_id": vp.target_printer_id,
  120. "library_folder_id": vp.library_folder_id,
  121. "queue_remove_after_print": vp.queue_remove_after_print,
  122. "auto_dispatch": vp.auto_dispatch,
  123. "queue_force_color_match": vp.queue_force_color_match,
  124. "save_ams_mapping": vp.save_ams_mapping,
  125. "gcode_injection": vp.gcode_injection,
  126. "bind_ip": vp.bind_ip,
  127. "remote_interface_ip": vp.remote_interface_ip,
  128. "tailscale_disabled": vp.tailscale_disabled,
  129. "position": vp.position,
  130. "status": status or {"running": False, "pending_files": 0},
  131. }
  132. @router.get("")
  133. async def list_virtual_printers(
  134. db: AsyncSession = Depends(get_db, scope="function"),
  135. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  136. printer_scope: PrinterScope = RequestPrinterScope,
  137. ):
  138. """List all virtual printers with status."""
  139. from backend.app.models.virtual_printer import VirtualPrinter
  140. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  141. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.position, VirtualPrinter.id))
  142. vps = result.scalars().all()
  143. printers = []
  144. for vp in vps:
  145. # One bridging to a printer the caller can't use is left out: its
  146. # status names that printer.
  147. if not printer_scope.allows(vp.target_printer_id):
  148. continue
  149. instance = virtual_printer_manager.get_instance(vp.id)
  150. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  151. printers.append(await _vp_to_dict(vp, db, status))
  152. from backend.app.services.virtual_printer.manager import MODEL_SERIAL_PREFIXES
  153. return {
  154. "printers": printers,
  155. "models": VIRTUAL_PRINTER_MODELS,
  156. # The serial family each model's VP gets (Bambu's first 3 characters),
  157. # which tells apart models of one name (H2C: O1C 094, O1C2 31B).
  158. "model_serial_prefixes": {code: prefix[:3] for code, prefix in MODEL_SERIAL_PREFIXES.items()},
  159. }
  160. @router.post("")
  161. async def create_virtual_printer(
  162. body: VirtualPrinterCreate,
  163. db: AsyncSession = Depends(get_db, scope="function"),
  164. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  165. printer_scope: PrinterScope = RequestPrinterScope,
  166. # Who the #3201 folder check runs against: an API key as its owner.
  167. actor: User | ApiKeyActor | None = RequestActor,
  168. ):
  169. """Create a new virtual printer."""
  170. from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
  171. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  172. from backend.app.services.virtual_printer.manager import DEFAULT_VIRTUAL_PRINTER_MODEL
  173. # Accept both canonical and legacy wire values so older clients (forks /
  174. # mobile shortcuts / scripted setups) still work; normalize before write.
  175. body.mode = normalize_vp_mode(body.mode) or body.mode
  176. if body.mode not in VP_MODE_VALUES:
  177. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  178. # Validate model
  179. if body.model and body.model not in VIRTUAL_PRINTER_MODELS:
  180. return JSONResponse(
  181. status_code=400,
  182. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  183. )
  184. # Validate access code length
  185. if body.access_code and len(body.access_code) != 8:
  186. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  187. # Validation when enabling. Non-proxy VPs with a target printer derive
  188. # their access code from the target (the bridge forwards the slicer's
  189. # auth bytes through to the real printer, so the codes MUST match),
  190. # so a separately-supplied access_code isn't required in that case.
  191. if body.enabled:
  192. if not body.bind_ip:
  193. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  194. if body.mode == "proxy":
  195. if not body.target_printer_id:
  196. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  197. else:
  198. if not body.access_code and not body.target_printer_id:
  199. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  200. # Validate proxy target printer exists
  201. target_printer = None
  202. if body.target_printer_id is not None:
  203. from backend.app.models.printer import Printer
  204. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  205. target_printer = result.scalar_one_or_none()
  206. if not target_printer or not printer_scope.allows(target_printer.id):
  207. return _target_missing(body.target_printer_id)
  208. if body.library_folder_id is not None and (
  209. refused := await _check_upload_folder(db, body.library_folder_id, actor)
  210. ):
  211. return refused
  212. # Validate bind_ip uniqueness (against all enabled VPs)
  213. if body.bind_ip:
  214. result = await db.execute(
  215. select(VirtualPrinter).where(
  216. VirtualPrinter.bind_ip == body.bind_ip,
  217. VirtualPrinter.enabled == True, # noqa: E712
  218. )
  219. )
  220. if result.scalar_one_or_none():
  221. return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
  222. # Force-inherit the access code from the target printer for non-proxy VPs.
  223. # The non-proxy bridge (Immediate / Review / Queue with a target set) forwards
  224. # the slicer's MQTT / RTSPS auth bytes through to the real printer, so any
  225. # value the user supplied here would silently break the bridge if it didn't
  226. # match the printer's code. The UI now renders the field read-only when a
  227. # target is set; this is the belt-and-braces backstop for any non-UI client.
  228. effective_access_code = body.access_code
  229. if body.mode != "proxy" and target_printer is not None:
  230. effective_access_code = target_printer.access_code
  231. # Generate next serial suffix
  232. result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
  233. last_suffix = result.scalar()
  234. if last_suffix:
  235. try:
  236. next_num = int(last_suffix) + 1
  237. new_suffix = str(next_num).zfill(9)
  238. except ValueError:
  239. new_suffix = "391800002"
  240. else:
  241. new_suffix = "391800001"
  242. # Get next position
  243. result = await db.execute(select(VirtualPrinter.position).order_by(VirtualPrinter.position.desc()))
  244. last_pos = result.scalar()
  245. next_pos = (last_pos or 0) + 1
  246. vp = VirtualPrinter(
  247. name=body.name,
  248. enabled=body.enabled,
  249. mode=body.mode,
  250. model=body.model
  251. or _resolve_printer_model(
  252. target_printer.model if target_printer and body.mode == "proxy" else None,
  253. target_printer.serial_number if target_printer else None,
  254. )
  255. or DEFAULT_VIRTUAL_PRINTER_MODEL,
  256. access_code=effective_access_code,
  257. target_printer_id=body.target_printer_id,
  258. library_folder_id=body.library_folder_id,
  259. queue_remove_after_print=body.queue_remove_after_print,
  260. auto_dispatch=body.auto_dispatch,
  261. queue_force_color_match=body.queue_force_color_match,
  262. save_ams_mapping=body.save_ams_mapping,
  263. gcode_injection=body.gcode_injection,
  264. bind_ip=body.bind_ip,
  265. remote_interface_ip=body.remote_interface_ip,
  266. serial_suffix=new_suffix,
  267. position=next_pos,
  268. )
  269. db.add(vp)
  270. await db.commit()
  271. await db.refresh(vp)
  272. logger.info("Created virtual printer: %s (id=%d)", vp.name, vp.id)
  273. # Sync services if enabled
  274. if body.enabled:
  275. try:
  276. await virtual_printer_manager.sync_from_db()
  277. except Exception as e:
  278. logger.error("Failed to start virtual printer after create: %s", e)
  279. return await _vp_to_dict(vp, db)
  280. @router.get("/tailscale-status", response_model=TailscaleStatusResponse)
  281. async def get_tailscale_status(
  282. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  283. ) -> TailscaleStatusResponse:
  284. """Return current Tailscale availability and machine identity.
  285. Used by the frontend to indicate whether virtual printer TLS is backed
  286. by a trusted Let's Encrypt certificate or a self-signed CA.
  287. """
  288. status = await tailscale_service.get_status()
  289. return TailscaleStatusResponse(
  290. available=status.available,
  291. fqdn=status.fqdn,
  292. hostname=status.hostname,
  293. tailnet_name=status.tailnet_name,
  294. tailscale_ips=status.tailscale_ips,
  295. error=status.error,
  296. )
  297. @router.get("/ca-certificate")
  298. async def get_ca_certificate(
  299. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  300. ):
  301. """Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
  302. One CA is shared by every virtual printer — the user imports it into their
  303. slicer's trust store once. Only the public certificate is returned; the CA
  304. private key never leaves the backend.
  305. """
  306. from backend.app.services.virtual_printer import virtual_printer_manager
  307. try:
  308. return virtual_printer_manager.get_ca_certificate_info()
  309. except Exception as e:
  310. logger.error("Failed to obtain virtual printer CA certificate: %s", e)
  311. return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
  312. @router.get("/ca-certificate/installer/{platform}")
  313. async def get_ca_certificate_installer(
  314. platform: str,
  315. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  316. ):
  317. """Return a script that adds the CA to Bambu Studio and OrcaSlicer.
  318. ``platform`` is ``windows`` (a .cmd) or ``unix`` (a .sh for Linux and
  319. macOS). The CA is written into the script, which then needs no access to
  320. Bambuddy. Returns ``{"filename", "content"}``.
  321. """
  322. from backend.app.services.virtual_printer import virtual_printer_manager
  323. from backend.app.services.virtual_printer.slicer_cert_installer import (
  324. INSTALLER_PLATFORMS,
  325. build_slicer_cert_installer,
  326. )
  327. if platform not in INSTALLER_PLATFORMS:
  328. return JSONResponse(status_code=404, content={"detail": f"Unknown platform '{platform}'"})
  329. try:
  330. info = virtual_printer_manager.get_ca_certificate_info()
  331. except Exception as e:
  332. logger.error("Failed to obtain virtual printer CA certificate: %s", e)
  333. return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
  334. installer = build_slicer_cert_installer(platform, info["pem"], info["fingerprint_sha256"])
  335. return {"filename": installer.filename, "content": installer.content}
  336. @router.get("/my-access-code")
  337. async def get_my_access_code(
  338. current_user: User = Depends(get_current_active_user),
  339. db: AsyncSession = Depends(get_db, scope="function"),
  340. ):
  341. """The signed-in user's personal access code for virtual printers (#1430).
  342. Entered in their slicer instead of a virtual printer's own code, it marks
  343. what they send as theirs. Made on first use. Only for a signed-in user (an
  344. API key has no slicer); a virtual printer checks at every sign-in whether
  345. the user may send to it.
  346. """
  347. from backend.app.services.virtual_printer.user_codes import code_for
  348. user = await db.get(User, current_user.id)
  349. if user is None:
  350. raise HTTPException(status_code=401, detail="Not signed in")
  351. return {"code": await code_for(db, user)}
  352. @router.post("/my-access-code")
  353. async def renew_my_access_code(
  354. current_user: User = Depends(get_current_active_user),
  355. db: AsyncSession = Depends(get_db, scope="function"),
  356. ):
  357. """Replace the signed-in user's personal access code; the old one stops working."""
  358. from backend.app.services.virtual_printer.user_codes import code_for
  359. user = await db.get(User, current_user.id)
  360. if user is None:
  361. raise HTTPException(status_code=401, detail="Not signed in")
  362. return {"code": await code_for(db, user, renew=True)}
  363. @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
  364. async def diagnose_virtual_printer(
  365. vp_id: int,
  366. db: AsyncSession = Depends(get_db, scope="function"),
  367. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  368. printer_scope: PrinterScope = RequestPrinterScope,
  369. ):
  370. """Run setup diagnostics for a virtual printer.
  371. Probes the VP's own bind IP and services so the user can self-diagnose the
  372. common "my virtual printer doesn't show up in the slicer" failures.
  373. """
  374. from backend.app.models.virtual_printer import VirtualPrinter
  375. from backend.app.services.virtual_printer import virtual_printer_manager
  376. from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
  377. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  378. vp = result.scalar_one_or_none()
  379. if not vp or not printer_scope.allows(vp.target_printer_id):
  380. return _vp_missing()
  381. instance = virtual_printer_manager.get_instance(vp.id)
  382. return await run_vp_diagnostic(vp, instance)
  383. @router.get("/{vp_id}")
  384. async def get_virtual_printer(
  385. vp_id: int,
  386. db: AsyncSession = Depends(get_db, scope="function"),
  387. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  388. printer_scope: PrinterScope = RequestPrinterScope,
  389. ):
  390. """Get a single virtual printer with status."""
  391. from backend.app.models.virtual_printer import VirtualPrinter
  392. from backend.app.services.virtual_printer import virtual_printer_manager
  393. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  394. vp = result.scalar_one_or_none()
  395. if not vp or not printer_scope.allows(vp.target_printer_id):
  396. return _vp_missing()
  397. instance = virtual_printer_manager.get_instance(vp.id)
  398. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  399. return await _vp_to_dict(vp, db, status)
  400. @router.put("/{vp_id}")
  401. async def update_virtual_printer(
  402. vp_id: int,
  403. body: VirtualPrinterUpdate,
  404. db: AsyncSession = Depends(get_db, scope="function"),
  405. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  406. printer_scope: PrinterScope = RequestPrinterScope,
  407. # Who the #3201 folder check runs against: an API key as its owner.
  408. actor: User | ApiKeyActor | None = RequestActor,
  409. ):
  410. """Update a virtual printer."""
  411. from backend.app.models.virtual_printer import VirtualPrinter
  412. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  413. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  414. vp = result.scalar_one_or_none()
  415. # One bridging to a printer the caller can't use is not theirs to change.
  416. if not vp or not printer_scope.allows(vp.target_printer_id):
  417. return _vp_missing()
  418. # Redact the access code before logging — model_dump otherwise includes
  419. # the plaintext value at DEBUG, violating the project no-secrets-in-logs
  420. # rule. Replace with a marker that still signals "the user changed it"
  421. # vs "the user didn't touch this field".
  422. _safe_body = body.model_dump(exclude_unset=True)
  423. if "access_code" in _safe_body:
  424. _safe_body["access_code"] = "***"
  425. logger.debug(
  426. "Update VP %d: body=%s, current state: mode=%s, enabled=%s, access_code_set=%s, bind_ip=%s, target=%s",
  427. vp_id,
  428. _safe_body,
  429. vp.mode,
  430. vp.enabled,
  431. bool(vp.access_code),
  432. vp.bind_ip,
  433. vp.target_printer_id,
  434. )
  435. # Apply updates
  436. if body.name is not None:
  437. vp.name = body.name
  438. if body.mode is not None:
  439. from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
  440. canonical_mode = normalize_vp_mode(body.mode) or body.mode
  441. if canonical_mode not in VP_MODE_VALUES:
  442. return JSONResponse(status_code=400, content={"detail": "Invalid mode"})
  443. vp.mode = canonical_mode
  444. if body.model is not None:
  445. if body.model not in VIRTUAL_PRINTER_MODELS:
  446. return JSONResponse(
  447. status_code=400,
  448. content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
  449. )
  450. vp.model = body.model
  451. if body.access_code is not None:
  452. if body.access_code and len(body.access_code) != 8:
  453. return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
  454. vp.access_code = body.access_code
  455. if "target_printer_id" in body.model_fields_set and body.target_printer_id is None:
  456. # An explicit null clears the target: a queue VP then sends its jobs
  457. # to any printer of its model. The access code stays what it was, so
  458. # a slicer already bound keeps working. A proxy VP needs a target, so
  459. # the enable checks below turn it off.
  460. vp.target_printer_id = None
  461. elif body.target_printer_id is not None:
  462. from backend.app.models.printer import Printer
  463. result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
  464. target_printer = result.scalar_one_or_none()
  465. if not target_printer or not printer_scope.allows(target_printer.id):
  466. return _target_missing(body.target_printer_id)
  467. vp.target_printer_id = body.target_printer_id
  468. # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
  469. if body.model is None and vp.mode == "proxy" and target_printer.model:
  470. vp.model = (
  471. _resolve_printer_model(target_printer.model, target_printer.serial_number) or target_printer.model
  472. )
  473. if "library_folder_id" in body.model_fields_set and body.library_folder_id is None:
  474. vp.library_folder_id = None # back to the shared "Virtual Printer" folder
  475. elif body.library_folder_id is not None:
  476. if refused := await _check_upload_folder(db, body.library_folder_id, actor):
  477. return refused
  478. vp.library_folder_id = body.library_folder_id
  479. if body.queue_remove_after_print is not None:
  480. vp.queue_remove_after_print = body.queue_remove_after_print
  481. if body.auto_dispatch is not None:
  482. vp.auto_dispatch = body.auto_dispatch
  483. if body.queue_force_color_match is not None:
  484. vp.queue_force_color_match = body.queue_force_color_match
  485. if body.save_ams_mapping is not None:
  486. vp.save_ams_mapping = body.save_ams_mapping
  487. if body.gcode_injection is not None:
  488. vp.gcode_injection = body.gcode_injection
  489. if body.bind_ip is not None:
  490. vp.bind_ip = body.bind_ip
  491. if body.remote_interface_ip is not None:
  492. vp.remote_interface_ip = body.remote_interface_ip
  493. if body.tailscale_disabled is not None:
  494. vp.tailscale_disabled = body.tailscale_disabled
  495. # Auto-inherit model when switching to proxy mode with existing target printer
  496. if body.mode == "proxy" and body.model is None and body.target_printer_id is None and vp.target_printer_id:
  497. from backend.app.models.printer import Printer as PrinterModel
  498. result = await db.execute(select(PrinterModel).where(PrinterModel.id == vp.target_printer_id))
  499. existing_target = result.scalar_one_or_none()
  500. if existing_target and existing_target.model:
  501. vp.model = (
  502. _resolve_printer_model(existing_target.model, existing_target.serial_number) or existing_target.model
  503. )
  504. # Force-inherit the access code from the target printer for non-proxy VPs.
  505. # See create_virtual_printer for the rationale: the bridge forwards slicer
  506. # auth bytes through, so the VP's code MUST equal the target's. This block
  507. # runs after every patch (whether or not access_code or target were in the
  508. # body), so changing the target also resyncs the code, and an explicit
  509. # access_code submitted alongside a target is silently overridden.
  510. if vp.mode != "proxy" and vp.target_printer_id is not None:
  511. from backend.app.models.printer import Printer as PrinterModelAC
  512. result = await db.execute(select(PrinterModelAC).where(PrinterModelAC.id == vp.target_printer_id))
  513. target_for_ac = result.scalar_one_or_none()
  514. if target_for_ac is not None and vp.access_code != target_for_ac.access_code:
  515. vp.access_code = target_for_ac.access_code
  516. # Determine final enabled state
  517. explicitly_enabling = body.enabled is True
  518. new_enabled = body.enabled if body.enabled is not None else vp.enabled
  519. effective_mode = vp.mode
  520. if explicitly_enabling:
  521. # User is explicitly toggling on — enforce all requirements
  522. if not vp.bind_ip:
  523. logger.warning("Update VP %d rejected: no bind_ip", vp_id)
  524. return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
  525. # Validate bind_ip uniqueness (against all enabled VPs)
  526. existing = await db.execute(
  527. select(VirtualPrinter).where(
  528. VirtualPrinter.bind_ip == vp.bind_ip,
  529. VirtualPrinter.id != vp_id,
  530. VirtualPrinter.enabled == True, # noqa: E712
  531. )
  532. )
  533. conflict = existing.scalar_one_or_none()
  534. if conflict:
  535. logger.warning(
  536. "Update VP %d rejected: bind_ip %s already in use by VP %d (enabled=%s, mode=%s)",
  537. vp_id,
  538. vp.bind_ip,
  539. conflict.id,
  540. conflict.enabled,
  541. conflict.mode,
  542. )
  543. return JSONResponse(
  544. status_code=400,
  545. content={
  546. "detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"
  547. if printer_scope.allows(conflict.target_printer_id)
  548. else f"Bind IP {vp.bind_ip} is already in use"
  549. },
  550. )
  551. if effective_mode == "proxy":
  552. if not vp.target_printer_id:
  553. logger.warning("Update VP %d rejected: no target_printer_id for proxy mode", vp_id)
  554. return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
  555. else:
  556. if not vp.access_code:
  557. logger.warning(
  558. "Update VP %d rejected: no access_code for non-proxy enable (mode=%s)", vp_id, effective_mode
  559. )
  560. return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
  561. elif new_enabled and body.enabled is None:
  562. # VP is already enabled and user is changing other fields —
  563. # auto-disable if new state doesn't meet requirements
  564. if not vp.bind_ip:
  565. new_enabled = False
  566. elif effective_mode == "proxy":
  567. if not vp.target_printer_id:
  568. new_enabled = False
  569. else:
  570. if not vp.access_code:
  571. new_enabled = False
  572. vp.enabled = new_enabled
  573. await db.commit()
  574. await db.refresh(vp)
  575. logger.info("Updated virtual printer: %s (id=%d)", vp.name, vp.id)
  576. # Sync services
  577. try:
  578. await virtual_printer_manager.sync_from_db()
  579. except Exception as e:
  580. logger.error("Failed to sync virtual printers after update: %s", e)
  581. instance = virtual_printer_manager.get_instance(vp.id)
  582. status = instance.get_status() if instance else {"running": False, "pending_files": 0}
  583. return await _vp_to_dict(vp, db, status)
  584. async def drop_virtual_printer(db: AsyncSession, vp_id: int) -> Path:
  585. """Delete a virtual printer's row and discard its pending uploads.
  586. Its instance must be stopped first, if one runs. The caller commits, then
  587. calls remove_upload_dir with what this returns. *db* is a session or a
  588. connection.
  589. """
  590. from sqlalchemy import delete as sql_delete
  591. from backend.app.models.pending_upload import PendingUpload
  592. from backend.app.models.virtual_printer import VirtualPrinter
  593. from backend.app.services.virtual_printer import virtual_printer_manager
  594. upload_dir = virtual_printer_manager._base_dir / "uploads" / str(vp_id)
  595. # Mark any PendingUpload rows that referenced this VP's upload_dir as
  596. # discarded — without this the rows live on as phantom entries in
  597. # /pending-uploads/ pointing at file paths that no longer exist, and
  598. # the user only learns they're orphaned by trying to archive one and
  599. # getting a flip-to-discarded on file-missing.
  600. try:
  601. await db.execute(
  602. update(PendingUpload)
  603. # The folder, not every path that starts with its name (uploads/12 for 1).
  604. .where(PendingUpload.file_path.startswith(f"{upload_dir}{os.sep}", autoescape=True))
  605. .values(status="discarded")
  606. )
  607. except Exception as e:
  608. logger.error("Failed to discard orphan PendingUpload rows for VP %d: %s", vp_id, e)
  609. await db.execute(sql_delete(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  610. return upload_dir
  611. def remove_upload_dir(upload_dir: Path) -> None:
  612. """Remove a deleted virtual printer's upload directory.
  613. After the DB commit succeeds, so a crash between commit and rmtree only
  614. leaves orphan files (vs orphan rows pointing at a now-missing tree).
  615. """
  616. if upload_dir.exists():
  617. import shutil
  618. shutil.rmtree(upload_dir, ignore_errors=True)
  619. @router.delete("/{vp_id}")
  620. async def delete_virtual_printer(
  621. vp_id: int,
  622. db: AsyncSession = Depends(get_db, scope="function"),
  623. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  624. printer_scope: PrinterScope = RequestPrinterScope,
  625. ):
  626. """Delete a virtual printer."""
  627. from backend.app.models.virtual_printer import VirtualPrinter
  628. from backend.app.services.virtual_printer import virtual_printer_manager
  629. result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
  630. vp = result.scalar_one_or_none()
  631. if not vp or not printer_scope.allows(vp.target_printer_id):
  632. return _vp_missing()
  633. vp_name = vp.name
  634. # Stop instance if running
  635. await virtual_printer_manager.remove_instance(vp_id)
  636. upload_dir = await drop_virtual_printer(db, vp_id)
  637. await db.commit()
  638. remove_upload_dir(upload_dir)
  639. logger.info("Deleted virtual printer: %s (id=%d)", vp_name, vp_id)
  640. # Resync remaining services
  641. try:
  642. await virtual_printer_manager.sync_from_db()
  643. except Exception as e:
  644. logger.error("Failed to sync virtual printers after delete: %s", e)
  645. return {"detail": "Deleted", "id": vp_id}