feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8) #36
4 changed files with 292 additions and 20 deletions
feat: return buyers to the calling app after Stripe, branded QR endpoint
`_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=<ids>`, 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://<id>` — 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
commit
f77ad28bdd
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
52
tests/test_frontend_root.py
Normal file
52
tests/test_frontend_root.py
Normal file
|
|
@ -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
|
||||
22
tests/test_ticket_qr.py
Normal file
22
tests/test_ticket_qr.py
Normal file
|
|
@ -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")
|
||||
235
views_api.py
235
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://<id>`), 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",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue