print_confirmation.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Post-print outcome confirmation helpers (#1898)."""
  2. import logging
  3. from collections.abc import Mapping
  4. from datetime import datetime, timezone
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.models.archive import PrintArchive
  8. from backend.app.models.print_log import PrintLogEntry
  9. logger = logging.getLogger(__name__)
  10. # How a verdict reached the archive. 'reaction' is written by the Telegram
  11. # reaction handler (#3046), which lives on its own branch — listed here so the
  12. # vocabulary is complete and the UI can label it the day that lands.
  13. VERDICT_SOURCES = ("dialog", "link", "plate_clear", "printer_card", "api", "reaction")
  14. # Link-preview unfurlers and mail-security scanners fetch every URL they find in
  15. # a message, unattended, within seconds of it being sent. Nothing they can do
  16. # with a GET records a verdict any more -- that is the POST route's job -- so
  17. # this list is the second layer: it decides whether the confirmation page
  18. # submits its own form, which is what keeps a human at one tap. A scanner that
  19. # runs JavaScript would otherwise press the button on the operator's behalf.
  20. # Matched as case-insensitive substrings of the User-Agent; the generic "bot"
  21. # token covers TelegramBot, Discordbot, Slackbot-LinkExpanding, Twitterbot and
  22. # LinkedInBot in one go.
  23. UNATTENDED_FETCH_AGENTS = (
  24. "bot",
  25. "crawler",
  26. "spider",
  27. "facebookexternalhit",
  28. "whatsapp",
  29. "skypeuripreview",
  30. "bingpreview",
  31. "safelinks",
  32. "urldefense",
  33. "proofpoint",
  34. "mimecast",
  35. "barracuda",
  36. "forcepoint",
  37. )
  38. # Prefetch / preload hints. A finger on a notification button is never one.
  39. UNATTENDED_FETCH_HEADERS = {
  40. "purpose": ("prefetch", "preview"),
  41. "x-purpose": ("prefetch", "preview"),
  42. "x-moz": ("prefetch",),
  43. "sec-purpose": ("prefetch",),
  44. }
  45. def is_unattended_fetch(method: str, headers: Mapping[str, str]) -> bool:
  46. """Whether a request for a one-tap verdict link came from a machine.
  47. Decides whether the confirmation page submits itself. False positives are
  48. deliberately cheap -- a browser mistaken for a bot gets the same page with
  49. a button to press -- so the lists above err towards catching more.
  50. """
  51. if method.upper() != "GET":
  52. # Anything that is not the page load is not a page load: only the GET
  53. # route renders, and only a GET can be widened to HEAD by a future
  54. # router change.
  55. return True
  56. for header, markers in UNATTENDED_FETCH_HEADERS.items():
  57. value = (headers.get(header) or "").lower()
  58. if value and any(marker in value for marker in markers):
  59. return True
  60. agent = (headers.get("user-agent") or "").lower()
  61. return any(marker in agent for marker in UNATTENDED_FETCH_AGENTS)
  62. # The one-tap marker. The confirmation page submits its own form only when the
  63. # URL it was opened from carries this, and the marker is put on exactly one
  64. # thing: the Telegram inline keyboard's buttons -- an affordance no unfurler,
  65. # gateway or proxy reads, for the same reason the capability URLs themselves no
  66. # longer travel in message text.
  67. #
  68. # It is what closes the gap the User-Agent list above cannot: a mail-security
  69. # sandbox that renders HTML and runs JavaScript sends an ordinary Chrome string
  70. # (so does literal HeadlessChrome), and a verdict URL that reached it did so out
  71. # of the message BODY -- where the marker never appears. That fetch now gets the
  72. # page with a button on it and records nothing. The operator's tap on the
  73. # notification button still costs exactly one tap.
  74. ONE_TAP_PARAM = "tap"
  75. def one_tap_url(url: str) -> str:
  76. """Mark a verdict URL as one a human is about to press.
  77. Only for the affordances a person taps directly. A URL that goes into text
  78. anybody's machine might follow is left unmarked on purpose.
  79. """
  80. return f"{url}{'&' if '?' in url else '?'}{ONE_TAP_PARAM}=1"
  81. def is_one_tap_request(query_params: Mapping[str, str]) -> bool:
  82. """Whether this page load came from a button rather than from message text."""
  83. return (query_params.get(ONE_TAP_PARAM) or "") == "1"
  84. def stamp_verdict(archive: PrintArchive, source: str) -> None:
  85. """Record a verdict's provenance and the moment it landed (#1898).
  86. Both fields move together on every verdict write, which is what keeps the
  87. "already answered" page from pairing a new source with the timestamp of an
  88. older decision. `retire_confirm_token` is separate on purpose: spending the
  89. one-tap capability happens once, recording a verdict can happen again.
  90. """
  91. archive.user_verdict_source = source
  92. archive.user_verdict_at = datetime.now(timezone.utc)
  93. def retire_confirm_token(archive: PrintArchive) -> None:
  94. """Spend the one-tap capability token without destroying it.
  95. The token is still single-use: once ``confirm_token_used_at`` is stamped,
  96. no verdict path accepts it again. Keeping the VALUE is what lets the
  97. one-tap route recognise a link belonging to an already-answered print and
  98. say so, instead of 404ing as if the link had never been real (the live-farm
  99. case: the plate-clear default answered the prompt, then the user tapped the
  100. Telegram button and got "invalid or already used").
  101. """
  102. if archive.confirm_token and archive.confirm_token_used_at is None:
  103. archive.confirm_token_used_at = datetime.now(timezone.utc)
  104. async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None:
  105. """Mark the printer's latest pending-confirmation archive as good.
  106. Backs the opt-in ``confirm_default_good_on_plate_clear`` setting: releasing
  107. the build plate is the moment the operator moves on to the next job, so an
  108. unanswered outcome prompt can default to "good part" right there instead of
  109. lingering as unconfirmed. Only the LATEST pending archive is resolved — the
  110. plate release refers to the print that just came off the plate, not to
  111. older unanswered prompts.
  112. Mirrors the verdict onto the latest PrintLogEntry (the #1444 mirror) and
  113. retires the one-tap capability token. Deliberately does NOT commit — both
  114. callers (the clear-plate route and the queue dispatcher) manage their own
  115. transaction.
  116. Returns the resolved archive id, or None when nothing was pending.
  117. """
  118. archive = await db.scalar(
  119. select(PrintArchive)
  120. .where(
  121. PrintArchive.printer_id == printer_id,
  122. PrintArchive.status == "completed",
  123. PrintArchive.confirm_requested.is_(True),
  124. PrintArchive.user_verdict.is_(None),
  125. )
  126. .order_by(PrintArchive.id.desc())
  127. .limit(1)
  128. )
  129. if archive is None:
  130. return None
  131. archive.user_verdict = "good"
  132. stamp_verdict(archive, "plate_clear")
  133. retire_confirm_token(archive)
  134. latest_entry = await db.scalar(
  135. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
  136. )
  137. if latest_entry is not None:
  138. latest_entry.user_verdict = "good"
  139. logger.info("[#1898] Plate clear defaulted archive %s to 'good' (printer %s)", archive.id, printer_id)
  140. return archive.id
  141. async def confirm_outcome_for_new_queue_item(db: AsyncSession, *, started_outside_bambuddy: bool = False) -> bool:
  142. """The ask-for-outcome flag for a queue item created without the print dialog.
  143. The dialog seeds its own per-job toggle from ``default_confirm_outcome``.
  144. Every other queue-creation path -- the virtual printer, the library bulk
  145. add, the webhook, a pipeline run -- has no toggle to seed and used to leave
  146. the column at its ``False`` default, so "Ask for Outcome" only ever reached
  147. jobs queued by hand.
  148. ``started_outside_bambuddy`` additionally honours
  149. ``confirm_outcome_external_prints``: a plate sent from Bambu Studio to a
  150. virtual printer is one of the prints that setting's description names, but
  151. it arrives with a queue item, so ``on_print_start`` never sees it as
  152. external and the setting could not otherwise reach it.
  153. """
  154. from backend.app.api.routes.settings import get_setting, setting_is_true
  155. if setting_is_true(await get_setting(db, "default_confirm_outcome")):
  156. return True
  157. return started_outside_bambuddy and setting_is_true(await get_setting(db, "confirm_outcome_external_prints"))