From db708cf0da43a583f088c820757455223ca253bd Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:53:05 +0200 Subject: [PATCH 1/7] feat: let a user_id ticket carry an email, accept frontend_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CreateTicket` no longer rejects `user_id` together with `name`/`email` (the exclusion was a fork-only dispatch convenience from dfabcb8; nothing needed it). `crud.create_ticket` stops blanking name/email when a user_id is present, so logged-in webapp buyers can have their ticket emailed — until now `_send_ticket_notification` short-circuited on the empty address for every app purchase. New optional `frontend_url` (absolute http(s) root, no query/fragment/.., trailing slash stripped) lets a buyer-side client name the app the buyer should be returned to and linked into from the ticket email; the origin allow-list lives in views_api. Also folds in the pending black reflow of migrations_fork.py. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- crud.py | 14 ++-- migrations_fork.py | 1 - models.py | 80 ++++++++++++++++++++++- tests/test_crud_ticket_email.py | 48 ++++++++++++++ tests/test_ticket_models.py | 110 ++++++++++++++++++++++++++++++++ 5 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 tests/test_crud_ticket_email.py create mode 100644 tests/test_ticket_models.py diff --git a/crud.py b/crud.py index 551a3bc..bd5d3c1 100644 --- a/crud.py +++ b/crud.py @@ -55,14 +55,12 @@ async def create_ticket( now = datetime.now(timezone.utc) row_id = ticket_id or payment_hash - # name/email columns are NOT NULL in the schema, so we store "" when only - # user_id is supplied. _parse_ticket_row reverses this on read. - if user_id: - db_name = "" - db_email = "" - else: - db_name = name or "" - db_email = email or "" + # name/email columns are NOT NULL in the schema, so we store "" when a + # value is absent. _parse_ticket_row reverses this on read. A user_id + # ticket may carry an email too — that is how logged-in webapp buyers get + # their ticket emailed. + db_name = name or "" + db_email = email or "" db_ticket = Ticket( id=row_id, diff --git a/migrations_fork.py b/migrations_fork.py index 864cbb8..ebe65a2 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -127,4 +127,3 @@ async def m002_ticket_payment_hash(db): "UPDATE events.ticket SET payment_hash = id " "WHERE payment_hash IS NULL OR payment_hash = ''" ) - diff --git a/models.py b/models.py index 7f1feac..e36c60a 100644 --- a/models.py +++ b/models.py @@ -1,8 +1,11 @@ import json from datetime import datetime +from urllib.parse import urlsplit from pydantic import BaseModel, EmailStr, Field, root_validator, validator +PAYMENT_METHODS = ("lightning", "fiat") + class PromoCode(BaseModel): code: str @@ -28,6 +31,26 @@ class EventExtra(BaseModel): nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" + # Rails the organizer accepts for this event. Empty = legacy rule + # ("lightning" always, "fiat" when allow_fiat) — see + # `effective_payment_methods`. Same field name/shape as upstream v2 so the + # eventual rebase (#33) merges cleanly. + payment_methods: list[str] = Field(default_factory=list) + + @validator("payment_methods", pre=True) + def normalize_payment_methods(cls, v): + if not v: + return [] + if isinstance(v, str): + v = v.split(",") + seen: list[str] = [] + for method in v: + method = str(method).strip().lower() + if method not in PAYMENT_METHODS: + raise ValueError(f"Unsupported payment method: {method}") + if method not in seen: + seen.append(method) + return seen class CreateEvent(BaseModel): @@ -107,6 +130,22 @@ class PublicEvent(BaseModel): return v or [] +def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> list[str]: + """Rails a buyer may pick for `event`. + + Explicit `extra.payment_methods` wins; an empty list falls back to the + pre-#payment-methods rule so events created before the field existed + keep behaving the same (Lightning always, fiat iff `allow_fiat`). + """ + explicit = list(getattr(event.extra, "payment_methods", []) or []) + if explicit: + return explicit + methods = ["lightning"] + if event.allow_fiat: + methods.append("fiat") + return methods + + class EventsSettings(BaseModel): """Extension-level settings for the events extension.""" @@ -136,16 +175,37 @@ class CreateTicket(BaseModel): # Number of tickets to buy on this single invoice. Bounded so a # bad client can't run away with the organizer's capacity. quantity: int = Field(default=1, ge=1, le=10) + # App root of the client that is buying (e.g. https://app.example/events). + # The extension builds the Stripe success/cancel URLs and the emailed + # ticket link under it, so the buyer lands back in the app they came + # from. Origin is allow-listed server-side (see `_resolve_frontend_root`); + # absent = today's behaviour (the LNbits host). + frontend_url: str | None = Field(default=None, max_length=512) + + @validator("frontend_url") + def validate_frontend_url(cls, v): + if v is None: + return None + v = v.strip() + if not v: + return None + parts = urlsplit(v) + if parts.scheme not in ("http", "https") or not parts.netloc: + raise ValueError("frontend_url must be an absolute http(s) URL") + if parts.query or parts.fragment or ".." in parts.path: + raise ValueError("frontend_url must not contain a query, fragment or '..'") + return v.rstrip("/") @root_validator def validate_identifiers(cls, values): + """A ticket needs an identity: an LNbits `user_id`, or `name` + + `email` for guests. A logged-in buyer may add `email` (and `name`) + on top of `user_id` so the ticket can be emailed to them.""" name = values.get("name") email = values.get("email") user_id = values.get("user_id") if not user_id and not (name and email): raise ValueError("Either user_id or both name and email must be provided") - if user_id and (name or email): - raise ValueError("Cannot provide both user_id and name/email") return values @@ -168,6 +228,22 @@ class Ticket(BaseModel): payment_hash: str | None = None +class NotificationDeliveryResult(BaseModel): + attempted: bool = False + sent: bool = False + error: str | None = None + + +class TicketResendResult(BaseModel): + ticket: Ticket + email: NotificationDeliveryResult = Field( + default_factory=NotificationDeliveryResult + ) + nostr: NotificationDeliveryResult = Field( + default_factory=NotificationDeliveryResult + ) + + class PublicTicket(BaseModel): event: str name: str | None = None diff --git a/tests/test_crud_ticket_email.py b/tests/test_crud_ticket_email.py new file mode 100644 index 0000000..de1d6ef --- /dev/null +++ b/tests/test_crud_ticket_email.py @@ -0,0 +1,48 @@ +from unittest.mock import AsyncMock + +import pytest + +from .. import crud + + +@pytest.mark.asyncio +async def test_create_ticket_keeps_email_alongside_user_id(monkeypatch): + inserted = {} + + async def fake_insert(table, model): + inserted["table"] = table + inserted["model"] = model + + monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert)) + + ticket = await crud.create_ticket( + payment_hash="hash", + wallet="w", + event="e", + name="Ada", + email="ada@example.com", + user_id="u1", + ticket_id="t1", + ) + + assert inserted["table"] == "events.ticket" + assert inserted["model"].user_id == "u1" + assert inserted["model"].email == "ada@example.com" + assert inserted["model"].name == "Ada" + assert ticket.email == "ada@example.com" + + +@pytest.mark.asyncio +async def test_create_ticket_stores_empty_string_sentinels(monkeypatch): + inserted = {} + + async def fake_insert(table, model): + inserted["model"] = model + + monkeypatch.setattr(crud.db, "insert", AsyncMock(side_effect=fake_insert)) + + await crud.create_ticket( + payment_hash="hash", wallet="w", event="e", user_id="u1", ticket_id="t2" + ) + assert inserted["model"].email == "" + assert inserted["model"].name == "" diff --git a/tests/test_ticket_models.py b/tests/test_ticket_models.py new file mode 100644 index 0000000..7d1ab3f --- /dev/null +++ b/tests/test_ticket_models.py @@ -0,0 +1,110 @@ +import pytest +from pydantic import ValidationError + +from ..models import ( + CreateEvent, + CreateTicket, + EventExtra, + effective_payment_methods, +) + + +def _ticket(**kwargs) -> CreateTicket: + return CreateTicket(**kwargs) + + +def test_user_id_only_is_a_valid_identity(): + assert _ticket(user_id="u1").user_id == "u1" + + +def test_name_and_email_is_a_valid_guest_identity(): + ticket = _ticket(name="Guest", email="guest@example.com") + assert ticket.user_id is None + assert ticket.email == "guest@example.com" + + +def test_user_id_may_carry_an_email_for_delivery(): + ticket = _ticket(user_id="u1", email="me@example.com") + assert ticket.user_id == "u1" + assert ticket.email == "me@example.com" + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"name": "Guest"}, + {"email": "guest@example.com"}, + ], +) +def test_missing_identity_is_rejected(kwargs): + with pytest.raises(ValidationError): + _ticket(**kwargs) + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://app.example/events", "https://app.example/events"), + ("https://app.example/events/", "https://app.example/events"), + ("http://localhost:5173/", "http://localhost:5173"), + (" ", None), + ], +) +def test_frontend_url_is_normalised(url, expected): + assert _ticket(user_id="u1", frontend_url=url).frontend_url == expected + + +@pytest.mark.parametrize( + "url", + [ + "/events", # relative + "ftp://app.example/events", + "https://app.example/events?x=1", + "https://app.example/events#top", + "https://app.example/../events", + "https://" + "a" * 520, + ], +) +def test_frontend_url_rejects_unsafe_values(url): + with pytest.raises(ValidationError): + _ticket(user_id="u1", frontend_url=url) + + +def _event(**overrides) -> CreateEvent: + data = { + "wallet": "w", + "name": "Test", + "info": "", + "closing_date": "2030-01-01", + "event_start_date": "2030-01-01", + "event_end_date": "2030-01-02", + "amount_tickets": 10, + "price_per_ticket": 5, + } + data.update(overrides) + return CreateEvent(**data) + + +def test_effective_payment_methods_legacy_rule(): + assert effective_payment_methods(_event()) == ["lightning"] + assert effective_payment_methods(_event(allow_fiat=True)) == ["lightning", "fiat"] + + +def test_effective_payment_methods_explicit_list_wins(): + event = _event(allow_fiat=True, extra=EventExtra(payment_methods=["fiat"])) + assert effective_payment_methods(event) == ["fiat"] + + +def test_payment_methods_are_normalised_and_deduplicated(): + extra = EventExtra(payment_methods=["Fiat", " lightning ", "fiat"]) + assert extra.payment_methods == ["fiat", "lightning"] + assert EventExtra(payment_methods="lightning,fiat").payment_methods == [ + "lightning", + "fiat", + ] + + +def test_unknown_payment_method_is_rejected(): + with pytest.raises(ValidationError): + EventExtra(payment_methods=["cash"]) -- 2.55.0 From f77ad28bdd9993aea25f640bbc637181e4f29965 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:53:05 +0200 Subject: [PATCH 2/7] 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", + }, + ) -- 2.55.0 From 92642a1f24194c82201074a731144477719e5e3c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:53:05 +0200 Subject: [PATCH 3/7] feat: multipart ticket email with embedded QR, structured resend result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of upstream v1.6.8's delivery layer, wave-free: `_deliver_ticket_ notifications` sends text + HTML (the HTML embeds the ticket QR PNG from this extension on the LNbits host — built from lnbits_baseurl on purpose, since ticket_base_url may point at a separate web app) and returns a `TicketResendResult` with per-channel attempted/sent/error. The SMTP session runs via asyncio.to_thread so a slow relay cannot stall the event loop while a batch of tickets settles. Resend keeps bypassing the per-event email opt-in (organizer asked explicitly) and is email-only. Our nsec-DM Nostr path is kept (upstream went NIP-05-only). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- services.py | 205 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 164 insertions(+), 41 deletions(-) diff --git a/services.py b/services.py index 0a2de28..83ac5c6 100644 --- a/services.py +++ b/services.py @@ -1,14 +1,16 @@ from __future__ import annotations import asyncio +import smtplib from asyncio.tasks import create_task +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from html import escape from lnbits.core.models.users import UserNotifications from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import ( - send_email_notification, - send_user_notification, -) +from lnbits.core.services.notifications import send_user_notification +from lnbits.helpers import is_valid_email_address from lnbits.settings import settings from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute @@ -21,7 +23,7 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, Ticket +from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .nostr_hooks import publish_or_delete_nostr_event DEFAULT_NOSTR_RELAYS = [ @@ -80,40 +82,13 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - subject, message = _ticket_notification_message(ticket, event) - updated = False - - if ( - event.extra.email_notifications - and settings.lnbits_email_notifications_enabled - and ticket.email - ): - try: - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - - if ( - event.extra.nostr_notifications - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ): - try: - await _send_nostr_ticket_notification( - ticket.extra.nostr_identifier, message - ) - ticket.extra.nostr_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - - if updated: - await update_ticket(ticket) + await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: +async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult: + """Organizer-triggered re-delivery of the ticket email. Bypasses the + per-event `email_notifications` opt-in (the organizer asked explicitly) + but still needs the instance mailer and an address on the ticket.""" event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -122,10 +97,7 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: if not ticket.email: raise ValueError("Ticket does not have an email address.") - subject, message = _ticket_notification_message(ticket, event) - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - return await update_ticket(ticket) + return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: @@ -142,6 +114,149 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str return subject, f"{body}\n\nOpen it here: {ticket_url}" +def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str: + return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}" + + +def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str: + text_message = _ticket_delivery_message(ticket, base_message) + html_message = f"

{escape(text_message).replace(chr(10), '
')}

" + image_url = escape(_ticket_image_url(ticket), quote=True) + return ( + f"{html_message}" + f'

Ticket QR code

' + ) + + +def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: + subject, base_message = _ticket_notification_message(ticket, event) + text_message = _ticket_delivery_message(ticket, base_message) + html_message = _ticket_email_html_message(ticket, base_message) + return subject, text_message, html_message + + +async def _deliver_ticket_notifications( + ticket: Ticket, + event: Event, + *, + email: bool | None = None, + nostr: bool | None = None, +) -> TicketResendResult: + """Send the ticket by every configured channel and report per-channel + outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's + opt-ins when not None; the instance-level prerequisites always apply.""" + subject, text_message, html_message = _ticket_notification_payload(ticket, event) + updated = False + + email_wanted = event.extra.email_notifications if email is None else email + nostr_wanted = event.extra.nostr_notifications if nostr is None else nostr + result = TicketResendResult( + ticket=ticket, + email=NotificationDeliveryResult( + attempted=bool( + email_wanted + and settings.lnbits_email_notifications_enabled + and ticket.email + ) + ), + nostr=NotificationDeliveryResult( + attempted=bool( + nostr_wanted + and settings.is_nostr_notifications_configured() + and ticket.extra.nostr_identifier + ) + ), + ) + + if result.email.attempted: + try: + assert ticket.email + await _send_ticket_email_notification( + [ticket.email], text_message, subject, html_message + ) + ticket.extra.email_notification_sent = True + result.email.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to email ticket {ticket.id}: {exc}") + result.email.error = str(exc) + + if result.nostr.attempted: + try: + identifier = ticket.extra.nostr_identifier + assert identifier + await _send_nostr_ticket_notification(identifier, text_message) + ticket.extra.nostr_notification_sent = True + result.nostr.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") + result.nostr.error = str(exc) + + if updated: + result.ticket = await update_ticket(ticket) + return result + + +async def _send_ticket_email_notification( + to_emails: list[str], + message: str, + subject: str, + html_message: str | None = None, +) -> None: + """Multipart (text + HTML) ticket email through the instance SMTP + settings. Core's `send_email_notification` is plain-text only, which is + why this lives here (ported from upstream v1.6.8). The blocking smtplib + session runs in a worker thread so a slow relay cannot stall the event + loop while a batch of tickets settles.""" + if not settings.lnbits_email_notifications_enabled: + raise ValueError("Email notifications are disabled") + from_email = settings.lnbits_email_notifications_email + if not is_valid_email_address(from_email): + raise ValueError(f"Invalid from email address: {from_email}") + if not to_emails: + raise ValueError("No email addresses provided") + for address in to_emails: + if not is_valid_email_address(address): + raise ValueError(f"Invalid email address: {address}") + + msg = MIMEMultipart("alternative") + msg["From"] = from_email + msg["To"] = ", ".join(to_emails) + msg["Subject"] = subject + msg.attach(MIMEText(message, "plain")) + if html_message: + msg.attach(MIMEText(html_message, "html")) + + username = settings.lnbits_email_notifications_username or from_email + await asyncio.to_thread( + _smtp_send, + settings.lnbits_email_notifications_server, + settings.lnbits_email_notifications_port, + username, + settings.lnbits_email_notifications_password, + from_email, + to_emails, + msg.as_string(), + ) + + +def _smtp_send( + server: str, + port: int, + username: str, + password: str, + from_email: str, + to_emails: list[str], + payload: str, +) -> None: + with smtplib.SMTP(server, port, timeout=30) as smtp_server: + smtp_server.starttls() + smtp_server.login(username, password) + smtp_server.sendmail(from_email, to_emails, payload) + + async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: if "@" in identifier: await send_user_notification( @@ -161,6 +276,14 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" +def _ticket_image_url(ticket: Ticket) -> str: + """The QR PNG is served by THIS extension on the LNbits host, so it is + built from `lnbits_baseurl` even when `ticket_base_url` points at a + separate web app (deviation from upstream, which assumes both are the + same host).""" + return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}" + + async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement. -- 2.55.0 From c2d9a962398074b5aa20822b6d1ce34b9b966112 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:53:05 +0200 Subject: [PATCH 4/7] feat: per-event payment methods (extra.payment_methods) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organizers pick which rails an event accepts — Lightning, card (fiat) or both — instead of a bare "allow fiat" toggle. `extra.payment_methods` uses the field name upstream v2 (lnbits/events#64) introduces so the eventual rebase merges cleanly; an empty list keeps the legacy rule (Lightning always, fiat when allow_fiat), and allow_fiat stays the fiat-currency carrier, kept in lockstep on save. The effective list is published as the NIP-52 tag `tickets_payment_methods` so clients render exactly the buttons the purchase endpoint will accept, and the buyer page defaults to the first accepted rail (a card-only event never submits "lightning"). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- nostr_publisher.py | 7 ++++++- static/js/display.js | 24 ++++++++++++++++++--- static/js/display.vue | 16 +++++++------- static/js/index.js | 36 ++++++++++++++++++++++--------- static/js/index.vue | 49 ++++++++++++++++++++++++++++++------------- 5 files changed, 94 insertions(+), 38 deletions(-) diff --git a/nostr_publisher.py b/nostr_publisher.py index 2588fcb..8374a62 100644 --- a/nostr_publisher.py +++ b/nostr_publisher.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from lnbits.core.signers import NostrSigner from loguru import logger -from .models import Event +from .models import Event, effective_payment_methods from .nostr.event import NostrEvent from .nostr_timestamp import monotonic_created_at @@ -110,6 +110,11 @@ def build_nip52_event(event: Event, pubkey: str) -> NostrEvent: tags.append(["tickets_allow_fiat", "true"]) if event.fiat_currency: tags.append(["tickets_fiat_currency", event.fiat_currency]) + # Rails the organizer accepts, resolved through the same helper the + # ticket endpoint enforces with, so a client can render exactly the + # buttons that will be accepted (e.g. a card-only event) without a REST + # round-trip. Comma-separated, lowercase. + tags.append(["tickets_payment_methods", ",".join(effective_payment_methods(event))]) # NIP-52 calendar events are replaceable: this d-tag is republished # whenever inventory changes (a ticket sells). Use a strictly-monotonic diff --git a/static/js/display.js b/static/js/display.js index d8be8e9..ad7e358 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -35,13 +35,29 @@ window.PageEventsDisplay = { async created() { this.eventId = this.$route.params.id this.event = await this.getEvent() + // Default to the first rail the organizer accepts (a card-only event + // must not submit "lightning"). + this.formDialog.data.payment_method = this.paymentMethods[0] || 'lightning' }, computed: { formatDescription() { return LNbits.utils.convertMarkdown(this.event?.info || '') }, + paymentMethods() { + // Mirrors `effective_payment_methods` on the backend: an explicit + // extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat. + const explicit = this.event?.extra?.payment_methods || [] + if (explicit.length) return explicit + return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])] + }, allowFiatCheckout() { - return Boolean(this.event?.allow_fiat) + return this.paymentMethods.includes('fiat') + }, + paymentMethodOptions() { + return this.paymentMethods.map(method => ({ + value: method, + label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning' + })) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' @@ -78,7 +94,8 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = 'lightning' + this.formDialog.data.payment_method = + this.paymentMethods[0] || 'lightning' }, closeReceiveDialog() { @@ -112,7 +129,8 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = 'lightning' + this.formDialog.data.payment_method = + this.paymentMethods[0] || 'lightning' Quasar.Notify.create({ type: 'positive', message: 'Sent, thank you!', diff --git a/static/js/display.vue b/static/js/display.vue index 9b27783..5a2ed68 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -90,20 +90,18 @@ :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" >
-
+
-
+
{ Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) }) @@ -326,7 +322,7 @@ window.PageEvents = { LNbits.utils .confirmDialog( 'Re-emit every approved event to Nostr relays? This is safe ' + - 'to run multiple times but generates one event per approved row.' + 'to run multiple times but generates one event per approved row.' ) .onOk(() => { this.republishing = true @@ -351,9 +347,7 @@ window.PageEvents = { }, republishMyEvents() { LNbits.utils - .confirmDialog( - 'Re-emit your approved events to Nostr relays?' - ) + .confirmDialog('Re-emit your approved events to Nostr relays?') .onOk(() => { this.republishingMine = true LNbits.api @@ -420,6 +414,17 @@ window.PageEvents = { code: code.code.trim().toUpperCase() })) } + const methods = data.extra?.payment_methods || [] + if (methods.length === 0) { + Quasar.Notify.create({ + type: 'warning', + message: 'Select at least one payment method.' + }) + return + } + // allow_fiat stays the fiat-currency carrier the backend and the + // NIP-52 tags read; keep it in lockstep with the checkbox list. + data.allow_fiat = methods.includes('fiat') if (!this.isFiatCurrency(data.currency)) { if (!data.allow_fiat) { data.fiat_currency = 'GBP' @@ -439,6 +444,15 @@ window.PageEvents = { const end = this.splitDateTime(data.event_end_date) this.formDialog.data = { ...data, + extra: { + ...(data.extra || {}), + // Events created before extra.payment_methods existed carry an + // empty list; show the rails the backend actually accepts for + // them (Lightning always, fiat when allow_fiat). + payment_methods: data.extra?.payment_methods?.length + ? data.extra.payment_methods + : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] + }, event_start_day: start.day, event_start_time: start.time, event_end_day: end.day, @@ -454,6 +468,7 @@ window.PageEvents = { event_end_day: '', event_end_time: '', extra: { + payment_methods: ['lightning'], conditional: false, min_tickets: 1, email_notifications: false, @@ -473,6 +488,7 @@ window.PageEvents = { allow_fiat: false, fiat_currency: 'GBP', extra: { + payment_methods: ['lightning'], email_notifications: false, nostr_notifications: false, promo_codes: [], diff --git a/static/js/index.vue b/static/js/index.vue index 6e6891f..502d9ad 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -20,10 +20,10 @@
Republish to Nostr
- Re-emit every approved event so connected clients pick - up the latest tag set. Useful after the extension - publisher changes (e.g. new tickets_* tags) so existing - events don't need a per-event edit. + Re-emit every approved event so connected clients pick up the + latest tag set. Useful after the extension publisher changes + (e.g. new tickets_* tags) so existing events don't need a + per-event edit.
@@ -56,8 +56,8 @@ >
- Re-emit your approved events to Nostr relays. Useful after - a publisher upgrade or if a relay dropped your events. + Re-emit your approved events to Nostr relays. Useful after a + publisher upgrade or if a relay dropped your events.
@@ -228,7 +228,13 @@ @@ -399,7 +405,13 @@ @@ -587,15 +599,22 @@ >
- +
+
Payment methods *
+ +
+ Card / fiat checkout goes through the fiat provider configured on + this LNbits instance. Untick Lightning for a card-only event. +
+
Date: Sun, 6 Sep 2026 19:56:55 +0200 Subject: [PATCH 6/7] chore: run tests against the aio lnbits fork (PYTHONPATH), not PyPI lnbits Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0fac253..d7e2d37 100644 --- a/Makefile +++ b/Makefile @@ -30,10 +30,15 @@ checkblack: checkeditorconfig: editorconfig-checker +# The uv env resolves *upstream* lnbits from PyPI, which lacks the aio fork's +# modules (lnbits.core.signers, …). Point PYTHONPATH at a fork checkout so +# `import lnbits` picks it up; override with LNBITS_SRC=/path/to/lnbits. +LNBITS_SRC ?= $(HOME)/dev/lnbits/dev test: + PYTHONPATH=$(LNBITS_SRC) \ PYTHONUNBUFFERED=1 \ DEBUG=true \ - uv run pytest + uv run --frozen pytest install-pre-commit-hook: @echo "Installing pre-commit hook to git" @echo "Uninstall the hook with uv run pre-commit uninstall" -- 2.55.0 From 07124b36aeb00f348a7cbb192fa7dabebcedc2a3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:57:25 +0200 Subject: [PATCH 7/7] chore: ignore the data/ dir pytest creates Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0152b6e..e31da20 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__ node_modules .mypy_cache .venv + +# lnbits data dir created by `make test` (settings default lnbits_data_folder) +data/ -- 2.55.0