diff --git a/README.md b/README.md index 43ab768..15748c7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ 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 @@ -33,6 +34,7 @@ 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) @@ -42,31 +44,6 @@ 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 394bc6d..01b145e 100644 --- a/__init__.py +++ b/__init__.py @@ -6,13 +6,12 @@ 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, qr_api_router, tickets_api_router +from .views_api import events_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 330240f..02272e3 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.8", + "version": "1.6.1-aio.7", "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 bd5d3c1..551a3bc 100644 --- a/crud.py +++ b/crud.py @@ -55,12 +55,14 @@ 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 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 "" + # 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 "" db_ticket = Ticket( id=row_id, diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md deleted file mode 100644 index b3246c2..0000000 --- a/docs/upstream-candidates.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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 ebe65a2..864cbb8 100644 --- a/migrations_fork.py +++ b/migrations_fork.py @@ -127,3 +127,4 @@ 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 e36c60a..7f1feac 100644 --- a/models.py +++ b/models.py @@ -1,11 +1,8 @@ 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 @@ -31,26 +28,6 @@ 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): @@ -130,22 +107,6 @@ 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.""" @@ -175,37 +136,16 @@ 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 @@ -228,22 +168,6 @@ 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 8374a62..2588fcb 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, effective_payment_methods +from .models import Event from .nostr.event import NostrEvent from .nostr_timestamp import monotonic_created_at @@ -110,11 +110,6 @@ 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 83ac5c6..0a2de28 100644 --- a/services.py +++ b/services.py @@ -1,16 +1,14 @@ 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_user_notification -from lnbits.helpers import is_valid_email_address +from lnbits.core.services.notifications import ( + send_email_notification, + send_user_notification, +) from lnbits.settings import settings from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute @@ -23,7 +21,7 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult +from .models import Event, Ticket from .nostr_hooks import publish_or_delete_nostr_event DEFAULT_NOSTR_RELAYS = [ @@ -82,13 +80,40 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - await _deliver_ticket_notifications(ticket, event) + 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) -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.""" +async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -97,7 +122,10 @@ async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult if not ticket.email: raise ValueError("Ticket does not have an email address.") - return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) + 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) def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: @@ -114,149 +142,6 @@ 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( @@ -276,14 +161,6 @@ 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 ad7e358..d8be8e9 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -35,29 +35,13 @@ 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 this.paymentMethods.includes('fiat') - }, - paymentMethodOptions() { - return this.paymentMethods.map(method => ({ - value: method, - label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning' - })) + return Boolean(this.event?.allow_fiat) }, fiatCheckoutLabel() { if (!this.allowFiatCheckout) return 'Fiat' @@ -94,8 +78,7 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = - this.paymentMethods[0] || 'lightning' + this.formDialog.data.payment_method = 'lightning' }, closeReceiveDialog() { @@ -129,8 +112,7 @@ window.PageEventsDisplay = { this.formDialog.data.email = '' this.formDialog.data.refund = '' this.formDialog.data.nostr_identifier = '' - this.formDialog.data.payment_method = - this.paymentMethods[0] || 'lightning' + this.formDialog.data.payment_method = 'lightning' Quasar.Notify.create({ type: 'positive', message: 'Sent, thank you!', diff --git a/static/js/display.vue b/static/js/display.vue index 5a2ed68..9b27783 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -90,18 +90,20 @@ :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" >
-
+
-
+
{ Quasar.Notify.create({type: 'positive', message: 'Settings saved'}) }) @@ -322,7 +326,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 @@ -347,7 +351,9 @@ 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 @@ -414,17 +420,6 @@ 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' @@ -444,15 +439,6 @@ 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, @@ -468,7 +454,6 @@ window.PageEvents = { event_end_day: '', event_end_time: '', extra: { - payment_methods: ['lightning'], conditional: false, min_tickets: 1, email_notifications: false, @@ -488,7 +473,6 @@ 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 502d9ad..6e6891f 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,13 +228,7 @@ @@ -405,13 +399,7 @@ @@ -599,22 +587,15 @@ >
-
-
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 deleted file mode 100644 index 376ea1b..0000000 --- a/tests/test_ticket_qr.py +++ /dev/null @@ -1,22 +0,0 @@ -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 8ae7b86..5ced0ef 100644 --- a/views_api.py +++ b/views_api.py @@ -1,13 +1,8 @@ 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, @@ -17,19 +12,18 @@ 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, @@ -37,8 +31,6 @@ 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, @@ -72,8 +64,6 @@ from .models import ( PublicTicket, Ticket, TicketPaymentRequest, - TicketResendResult, - effective_payment_methods, ) from .nostr_hooks import publish_or_delete_nostr_event from .services import ( @@ -86,7 +76,6 @@ 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: @@ -524,136 +513,6 @@ 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, @@ -663,7 +522,7 @@ async def _issue_free_tickets( user_id: str | None, promo_code: str | None, nostr_identifier: str | None, - frontend_root: str, + request: Request, ) -> TicketPaymentRequest: """Issue `quantity` free tickets without minting an invoice. @@ -693,7 +552,7 @@ async def _issue_free_tickets( extra={ "applied_promo_code": promo_code, "nostr_identifier": nostr_identifier, - "ticket_base_url": frontend_root, + "ticket_base_url": str(request.base_url).rstrip("/"), "sats_paid": 0, }, ) @@ -729,9 +588,7 @@ 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( @@ -761,7 +618,6 @@ 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 @@ -789,16 +645,13 @@ async def api_ticket_create( user_id=user_id, promo_code=promo_code, nostr_identifier=nostr_identifier, - frontend_root=frontend_root, + request=request, ) - # 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): + if payment_method == "fiat" and not event.allow_fiat: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail="Payment method not enabled for this event.", + detail="Fiat payments are not enabled for this event.", ) if _is_fiat_currency(event.currency): @@ -839,36 +692,6 @@ 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( @@ -880,8 +703,15 @@ 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 row_id in ticket_ids: + for _ in range(quantity): + row_id = urlsafe_short_hash() await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -894,10 +724,11 @@ async def api_ticket_create( "applied_promo_code": promo_code, "refund_address": refund_address, "nostr_identifier": nostr_identifier, - "ticket_base_url": frontend_root, + "ticket_base_url": str(request.base_url).rstrip("/"), "sats_paid": sats_per_ticket, }, ) + ticket_ids.append(row_id) return TicketPaymentRequest( payment_hash=payment.payment_hash, @@ -993,10 +824,10 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) +@tickets_api_router.post("/{ticket_id}/resend-email") async def api_ticket_resend_email( ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -) -> TicketResendResult: +) -> Ticket: ticket = await get_ticket(ticket_id) if not ticket: raise HTTPException( @@ -1128,31 +959,3 @@ 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", - }, - )