From f77ad28bdd9993aea25f640bbc637181e4f29965 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:53:05 +0200 Subject: [PATCH] feat: return buyers to the calling app after Stripe, branded QR endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_frontend_root` honours `CreateTicket.frontend_url` when its origin is one of LNBITS_CORS_ALLOWED_ORIGINS, the LNbits base URL or LNBITS_CUSTOM_FRONTEND_URL (400 otherwise — a silent fallback would send the buyer to the wrong app), and falls back to request.base_url as before. Under that root the fiat path now parameterises the hosted checkout via `extra["checkout"]` (lnbits StripeCheckoutOptions): success_url `/events/{id}?checkout=success&tickets=`, cancel_url `/events/{id}?checkout=cancelled`, customer_email, an event-named line item and event_id/quantity/ticket_ids metadata. Ticket ids are minted before the invoice so the success URL can carry them (the payment_hash only exists afterwards). ticket_base_url on the rows uses the same root, so the emailed link lands in the webapp when the webapp was the client. Purchases are gated on `effective_payment_methods(event)` (checked after the free-ticket short-circuit, which charges nothing on any rail). New anonymous `GET /events/api/v1/qr/{ticket_id}` returns a PNG of `ticket://` — port of upstream v1.6.8's endpoint without ticket-image compositing — built at error-correction H with the instance QR logo (`lnbits_qr_logo`) pasted in the centre, matching the client-side QRs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- __init__.py | 3 +- tests/test_frontend_root.py | 52 ++++++++ tests/test_ticket_qr.py | 22 ++++ views_api.py | 235 +++++++++++++++++++++++++++++++++--- 4 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 tests/test_frontend_root.py create mode 100644 tests/test_ticket_qr.py diff --git a/__init__.py b/__init__.py index 01b145e..394bc6d 100644 --- a/__init__.py +++ b/__init__.py @@ -6,12 +6,13 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router, tickets_api_router +from .views_api import events_api_router, qr_api_router, tickets_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) events_ext.include_router(tickets_api_router) +events_ext.include_router(qr_api_router) events_static_files = [ { diff --git a/tests/test_frontend_root.py b/tests/test_frontend_root.py new file mode 100644 index 0000000..e725de7 --- /dev/null +++ b/tests/test_frontend_root.py @@ -0,0 +1,52 @@ +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from lnbits.settings import settings + +from ..models import CreateTicket +from ..views_api import _allowed_frontend_origins, _resolve_frontend_root + + +@pytest.fixture +def lnbits_settings(monkeypatch): + monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") + monkeypatch.setattr( + settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False + ) + monkeypatch.setattr( + settings, + "lnbits_custom_frontend_url", + "https://Front.Example/login", + raising=False, + ) + + +def _request(base_url: str = "https://lnbits.example/"): + return SimpleNamespace(base_url=base_url) + + +def test_allowlist_collects_every_configured_origin(lnbits_settings): + assert _allowed_frontend_origins() == { + "https://lnbits.example", + "https://app.example", + "https://front.example", + } + + +def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings): + data = CreateTicket(user_id="u1") + assert _resolve_frontend_root(data, _request()) == "https://lnbits.example" + + +def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings): + data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/") + assert _resolve_frontend_root(data, _request()) == "https://app.example/events" + + +def test_unlisted_origin_is_rejected_loudly(lnbits_settings): + data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events") + with pytest.raises(HTTPException) as exc: + _resolve_frontend_root(data, _request()) + assert exc.value.status_code == 400 + assert "frontend_url" in exc.value.detail diff --git a/tests/test_ticket_qr.py b/tests/test_ticket_qr.py new file mode 100644 index 0000000..376ea1b --- /dev/null +++ b/tests/test_ticket_qr.py @@ -0,0 +1,22 @@ +from io import BytesIO + +from PIL import Image + +from ..views_api import make_qr_png + + +def test_make_qr_png_renders_requested_size(): + img = make_qr_png("ticket://abc123", size=200) + assert img.size == (200, 200) + + +def test_make_qr_png_pastes_a_centred_logo(): + logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255)) + img = make_qr_png("ticket://abc123", size=300, logo=logo) + assert img.size == (300, 300) + # The centre pixel is inside the pasted logo, so it is red — a plain + # QR would only ever have black or white there. + assert img.getpixel((150, 150))[:3] == (255, 0, 0) + out = BytesIO() + img.save(out, format="PNG") + assert out.getvalue().startswith(b"\x89PNG") diff --git a/views_api.py b/views_api.py index 5ced0ef..8ae7b86 100644 --- a/views_api.py +++ b/views_api.py @@ -1,8 +1,13 @@ import asyncio from datetime import datetime, timezone from http import HTTPStatus +from io import BytesIO +from pathlib import Path from typing import Any +from urllib.parse import urlsplit +import httpx +import pyqrcode # type: ignore[import-untyped] from fastapi import ( APIRouter, Depends, @@ -12,18 +17,19 @@ from fastapi import ( WebSocket, WebSocketDisconnect, ) +from fastapi.responses import StreamingResponse from lnbits.core.crud import get_user from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import Account, User, WalletTypeInfo from lnbits.core.models.payments import CreateInvoice from lnbits.core.services import create_payment_request -from lnbits.helpers import urlsafe_short_hash from lnbits.decorators import ( check_admin, check_user_exists, require_admin_key, require_invoice_key, ) +from lnbits.helpers import urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, @@ -31,6 +37,8 @@ from lnbits.utils.exchange_rates import ( satoshis_amount_as_fiat, ) from lnbits.utils.nostr import normalize_public_key +from loguru import logger +from PIL import Image, ImageDraw from .crud import ( create_event, @@ -64,6 +72,8 @@ from .models import ( PublicTicket, Ticket, TicketPaymentRequest, + TicketResendResult, + effective_payment_methods, ) from .nostr_hooks import publish_or_delete_nostr_event from .services import ( @@ -76,6 +86,7 @@ from .tasks import deregister_payment_listener, register_payment_listener events_api_router = APIRouter(prefix="/api/v1/events") tickets_api_router = APIRouter(prefix="/api/v1/tickets") +qr_api_router = APIRouter(prefix="/api/v1") def _is_fiat_currency(currency: str | None) -> bool: @@ -513,6 +524,136 @@ async def api_get_ticket(ticket_id: str) -> Ticket: return ticket +def _origin(url: str | None) -> str | None: + if not url: + return None + parts = urlsplit(url.strip()) + if not parts.scheme or not parts.netloc: + return None + return f"{parts.scheme.lower()}://{parts.netloc.lower()}" + + +def _allowed_frontend_origins() -> set[str]: + """Origins a buyer-side client may name in `CreateTicket.frontend_url`. + + The CORS allow-list is literally "which web apps may talk to this + LNbits", so it is the natural allow-list for "which web apps may be + linked from a ticket email". The LNbits host itself and the configured + custom frontend are always fine. + """ + origins: set[str] = set() + for candidate in [ + *getattr(settings, "lnbits_cors_allowed_origins", []), + settings.lnbits_baseurl, + getattr(settings, "lnbits_custom_frontend_url", None), + ]: + origin = _origin(candidate) + if origin: + origins.add(origin) + return origins + + +def _resolve_frontend_root(data: CreateTicket, request: Request) -> str: + """Root under which `/events/{event_id}` and `/events/ticket/{ticket_id}` + resolve for the buyer — the calling app when it says so, else the LNbits + host (the extension's own Quasar pages).""" + if not data.frontend_url: + return str(request.base_url).rstrip("/") + origin = _origin(data.frontend_url) + if not origin or origin not in _allowed_frontend_origins(): + # Fail loud rather than silently falling back: a wrong root means + # the buyer is returned to (and emailed a link into) the wrong app. + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="frontend_url origin is not allowed.", + ) + return data.frontend_url.rstrip("/") + + +_qr_logo_cache: dict[str, Image.Image | None] = {} + + +async def _load_qr_logo() -> Image.Image | None: + """LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached). + + Local `/static/...` values resolve inside the LNbits package; absolute + URLs are fetched once. Any failure just yields a plain QR. + """ + source = (settings.lnbits_qr_logo or "").strip() + if not source: + return None + if source in _qr_logo_cache: + return _qr_logo_cache[source] + logo: Image.Image | None = None + try: + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(source) + resp.raise_for_status() + logo = Image.open(BytesIO(resp.content)).convert("RGBA") + else: + local = Path(settings.lnbits_path) / source.lstrip("/") + if local.is_file(): + logo = Image.open(local).convert("RGBA") + except Exception as exc: + logger.warning(f"QR logo '{source}' unavailable: {exc}") + logo = None + _qr_logo_cache[source] = logo + return logo + + +def make_qr_png( + data: str, + size: int = 235, + border: int = 4, + logo: Image.Image | None = None, +) -> Image.Image: + """Render `data` as a QR image (upstream v1.6.8 shape). With `logo`, the + code is built at error-correction level H and the logo is pasted in the + centre on a white pad at ≤ 20 % of the width — the same look LNbits' + client-side `lnbits-qrcode` component produces.""" + qr = pyqrcode.create(data, error="H" if logo is not None else "M") + matrix = qr.code + modules = len(matrix) + + total_modules = modules + border * 2 + box_size = max(1, size // total_modules) + img_size = total_modules * box_size + + img = Image.new("RGBA", (img_size, img_size), "white") + draw = ImageDraw.Draw(img) + + for y, row in enumerate(matrix): + for x, cell in enumerate(row): + if cell: + x0 = (x + border) * box_size + y0 = (y + border) * box_size + draw.rectangle( + [x0, y0, x0 + box_size - 1, y0 + box_size - 1], + fill="black", + ) + + if img_size != size: + img = img.resize((size, size), Image.Resampling.NEAREST) + + if logo is not None: + logo_size = max(8, int(size * 0.2)) + pad = max(2, logo_size // 8) + scaled = logo.copy() + scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS) + plate = Image.new( + "RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white" + ) + plate.paste(scaled, (pad, pad), scaled) + img.paste( + plate, + ((size - plate.width) // 2, (size - plate.height) // 2), + plate, + ) + + return img + + async def _issue_free_tickets( *, event: Event, @@ -522,7 +663,7 @@ async def _issue_free_tickets( user_id: str | None, promo_code: str | None, nostr_identifier: str | None, - request: Request, + frontend_root: str, ) -> TicketPaymentRequest: """Issue `quantity` free tickets without minting an invoice. @@ -552,7 +693,7 @@ async def _issue_free_tickets( extra={ "applied_promo_code": promo_code, "nostr_identifier": nostr_identifier, - "ticket_base_url": str(request.base_url).rstrip("/"), + "ticket_base_url": frontend_root, "sats_paid": 0, }, ) @@ -588,7 +729,9 @@ async def api_ticket_create( quantity = data.quantity if event.amount_tickets > 0: if event.sold >= event.amount_tickets: - raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") + raise HTTPException( + status_code=HTTPStatus.GONE, detail="Event is sold out." + ) remaining = event.amount_tickets - event.sold if quantity > remaining: raise HTTPException( @@ -618,6 +761,7 @@ async def api_ticket_create( ) from exc unit_price = event.price_per_ticket extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} + frontend_root = _resolve_frontend_root(data, request) if promo_code: # check if promo_code exists in event.extra.promo_codes @@ -645,13 +789,16 @@ async def api_ticket_create( user_id=user_id, promo_code=promo_code, nostr_identifier=nostr_identifier, - request=request, + frontend_root=frontend_root, ) - if payment_method == "fiat" and not event.allow_fiat: + # Organizer-controlled rails (extra.payment_methods; legacy events fall + # back to Lightning + fiat-if-allow_fiat). Checked after the free path + # because a free claim charges nothing on any rail. + if payment_method not in effective_payment_methods(event): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Fiat payments are not enabled for this event.", + detail="Payment method not enabled for this event.", ) if _is_fiat_currency(event.currency): @@ -692,6 +839,36 @@ async def api_ticket_create( else: invoice_unit = "sat" + # Each row gets a fresh urlsafe_short_hash id so single- and + # multi-ticket purchases stay shape-consistent — every scannable + # ticket id is a short hash, never the long bolt11 payment_hash. + # The shared `payment_hash` column is the join key for invoice + # lookup (poll endpoint, ws notifier, set_ticket_paid loop). Ids are + # minted BEFORE the invoice so the fiat success URL can carry them + # (the payment_hash only exists after `create_payment_request`). + ticket_ids: list[str] = [urlsafe_short_hash() for _ in range(quantity)] + + if payment_method == "fiat": + # Parameterise the provider's hosted checkout (consumed by + # lnbits/fiat/stripe.py `StripeCheckoutOptions`): bring the buyer + # back to the app they came from, lock the email they gave us, and + # label the line item with the event rather than the raw memo. + ticket_label = "ticket" if quantity == 1 else "tickets" + extra["checkout"] = { + "success_url": ( + f"{frontend_root}/events/{event.id}" + f"?checkout=success&tickets={','.join(ticket_ids)}" + ), + "cancel_url": f"{frontend_root}/events/{event.id}?checkout=cancelled", + "customer_email": email, + "line_item_name": f"{event.name} — {quantity} {ticket_label}", + "metadata": { + "event_id": event.id, + "quantity": str(quantity), + "ticket_ids": ",".join(ticket_ids), + }, + } + payment = await create_payment_request( wallet_id=event.wallet, invoice_data=CreateInvoice( @@ -703,15 +880,8 @@ async def api_ticket_create( extra=extra, ), ) - # Each row gets a fresh urlsafe_short_hash id so single- and - # multi-ticket purchases stay shape-consistent — every scannable - # ticket id is a short hash, never the long bolt11 payment_hash. - # The shared `payment_hash` column is the join key for invoice - # lookup (poll endpoint, ws notifier, set_ticket_paid loop). - ticket_ids: list[str] = [] sats_per_ticket = payment.sat // quantity if quantity else payment.sat - for _ in range(quantity): - row_id = urlsafe_short_hash() + for row_id in ticket_ids: await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -724,11 +894,10 @@ async def api_ticket_create( "applied_promo_code": promo_code, "refund_address": refund_address, "nostr_identifier": nostr_identifier, - "ticket_base_url": str(request.base_url).rstrip("/"), + "ticket_base_url": frontend_root, "sats_paid": sats_per_ticket, }, ) - ticket_ids.append(row_id) return TicketPaymentRequest( payment_hash=payment.payment_hash, @@ -824,10 +993,10 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email") +@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -) -> Ticket: +) -> TicketResendResult: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -959,3 +1128,31 @@ async def api_event_ticket_stats( for t in paid_tickets ], } + + +@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse) +async def api_ticket_qr(ticket_id: str): + """PNG of the ticket's scan payload (`ticket://`), branded with the + instance QR logo. Anonymous by design — it is what the ticket email + embeds — and the id is the same bearer token the ticket page exposes. + Port of upstream v1.6.8 without ticket-image compositing.""" + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + + logo = await _load_qr_logo() + image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo) + output = BytesIO() + image.save(output, format="PNG") + output.seek(0) + return StreamingResponse( + output, + media_type="image/png", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + )