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/ 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" diff --git a/README.md b/README.md index 15748c7..43ab768 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Events includes a shareable ticket scanner, which can be used to register attend 1. Create an event\ ![create event](https://i.imgur.com/dadK1dp.jpg) 2. Fill out the event information: - - event name - wallet (normally there's only one) - event information @@ -34,7 +33,6 @@ Events includes a shareable ticket scanner, which can be used to register attend 3. Share the event registration link\ ![event ticket](https://imgur.com/AQWUOBY.jpg) - - ticket example\ ![ticket example](https://i.imgur.com/trAVSLd.jpg) @@ -44,6 +42,31 @@ Events includes a shareable ticket scanner, which can be used to register attend 4. Use the built-in ticket scanner to validate registered, and paid, attendees\ ![ticket scanner](https://i.imgur.com/zrm9202.jpg) +## Guest checkout, card payments and email delivery (aio fork) + +- **Identity.** `POST /events/api/v1/tickets/{event_id}` accepts either an + LNbits `user_id` or a guest `name` + `email`; a `user_id` ticket may also + carry an `email` so logged-in buyers get their ticket mailed. +- **Payment methods.** `extra.payment_methods` (`lightning`, `fiat`) lists the + rails an event accepts; an empty list keeps the legacy rule (Lightning always, + fiat when `allow_fiat`). The effective list is published on the NIP-52 event + as `tickets_payment_methods` and enforced at purchase. +- **Return to the calling app.** A client may send `frontend_url` (its app + root, e.g. `https://app.example/events`). Its origin must be one of + `LNBITS_CORS_ALLOWED_ORIGINS`, the LNbits base URL or + `LNBITS_CUSTOM_FRONTEND_URL`, otherwise the request is refused. Under that + root the extension builds the Stripe `success_url` + (`/events/{event_id}?checkout=success&tickets=`), `cancel_url` + (`/events/{event_id}?checkout=cancelled`) and the emailed ticket link + (`/events/ticket/{ticket_id}`). Absent, the LNbits host is used as before. +- **Stripe session.** The buyer's email is passed as `customer_email` + (prefilled and locked on the hosted page); the line item is named after the + event; `event_id`, `quantity` and `ticket_ids` ride along as metadata. +- **Email.** Multipart text + HTML with the ticket QR embedded from + `GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo). + `POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a + `TicketResendResult` with per-channel outcome. + ## Powered by LNbits [LNbits](https://lnbits.com) is a free and open-source lightning accounts system. 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/config.json b/config.json index 02272e3..330240f 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.7", + "version": "1.6.1-aio.8", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", 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/docs/upstream-candidates.md b/docs/upstream-candidates.md new file mode 100644 index 0000000..b3246c2 --- /dev/null +++ b/docs/upstream-candidates.md @@ -0,0 +1,17 @@ +# Upstream PR candidates + +Running log of fork features that are shaped so they could be offered to +`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here +in an upstream-compatible form; strike it when the PR merges upstream. + +| Feature | Where | Upstream target | Readiness | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR | +| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above | +| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | 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/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/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. 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. +
+
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"]) 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", + }, + )