feat: guest checkout, Stripe return flow, emailed QR tickets, per-event payment methods (v1.6.1-aio.8) #36
20 changed files with 844 additions and 114 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@ __pycache__
|
|||
node_modules
|
||||
.mypy_cache
|
||||
.venv
|
||||
|
||||
# lnbits data dir created by `make test` (settings default lnbits_data_folder)
|
||||
data/
|
||||
|
|
|
|||
7
Makefile
7
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"
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -23,7 +23,6 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
|||
1. Create an event\
|
||||

|
||||
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\
|
||||

|
||||
|
||||
- ticket example\
|
||||

|
||||
|
||||
|
|
@ -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\
|
||||

|
||||
|
||||
## 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=<id,id>`), `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.
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
crud.py
14
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,
|
||||
|
|
|
|||
17
docs/upstream-candidates.md
Normal file
17
docs/upstream-candidates.md
Normal file
|
|
@ -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 |
|
||||
|
|
@ -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 = ''"
|
||||
)
|
||||
|
||||
|
|
|
|||
80
models.py
80
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
205
services.py
205
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"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
|
||||
image_url = escape(_ticket_image_url(ticket), quote=True)
|
||||
return (
|
||||
f"{html_message}"
|
||||
f'<p><img src="{image_url}" alt="Ticket QR code" '
|
||||
'style="max-width: 300px; height: auto;" /></p>'
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -90,20 +90,18 @@
|
|||
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||
></q-input>
|
||||
<div class="row q-col-gutter-md q-pt-lg items-center">
|
||||
<div v-if="allowFiatCheckout" class="col-auto">
|
||||
<div v-if="paymentMethods.length > 1" class="col-auto">
|
||||
<q-option-group
|
||||
v-model="formDialog.data.payment_method"
|
||||
inline
|
||||
:options="[
|
||||
{label: 'Lightning', value: 'lightning'},
|
||||
{
|
||||
label: fiatCheckoutLabel,
|
||||
value: 'fiat'
|
||||
}
|
||||
]"
|
||||
:options="paymentMethodOptions"
|
||||
></q-option-group>
|
||||
</div>
|
||||
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
|
||||
<div
|
||||
:class="
|
||||
paymentMethods.length > 1 ? 'col-12 col-md-3' : 'col-12'
|
||||
"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ window.PageEvents = {
|
|||
allow_fiat: false,
|
||||
fiat_currency: 'GBP',
|
||||
extra: {
|
||||
payment_methods: ['lightning'],
|
||||
promo_codes: [],
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
|
|
@ -271,12 +272,7 @@ window.PageEvents = {
|
|||
},
|
||||
saveSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/events/api/v1/events/settings',
|
||||
null,
|
||||
this.settings
|
||||
)
|
||||
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
|
||||
.then(() => {
|
||||
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: [],
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
<div class="col">
|
||||
<span class="text-subtitle2">Republish to Nostr</span>
|
||||
<div class="text-caption text-grey-7" style="color: #aaa">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
|
|
@ -56,8 +56,8 @@
|
|||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption q-mt-sm" style="color: #aaa">
|
||||
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.
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -228,7 +228,13 @@
|
|||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-badge
|
||||
v-if="col.name === 'status'"
|
||||
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||
:color="
|
||||
col.value === 'approved'
|
||||
? 'green'
|
||||
: col.value === 'proposed'
|
||||
? 'orange'
|
||||
: 'red'
|
||||
"
|
||||
:label="col.value"
|
||||
></q-badge>
|
||||
<span v-else v-text="col.value"></span>
|
||||
|
|
@ -399,7 +405,13 @@
|
|||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<q-badge
|
||||
v-if="col.name === 'status'"
|
||||
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
||||
:color="
|
||||
col.value === 'approved'
|
||||
? 'green'
|
||||
: col.value === 'proposed'
|
||||
? 'orange'
|
||||
: 'red'
|
||||
"
|
||||
:label="col.value"
|
||||
></q-badge>
|
||||
<span v-else v-text="col.value"></span>
|
||||
|
|
@ -587,15 +599,22 @@
|
|||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.allow_fiat"
|
||||
label="Allow fiat checkout"
|
||||
left-label
|
||||
hint="Lets attendees pay through a configured fiat provider using the event currency."
|
||||
></q-toggle>
|
||||
<div class="q-mt-sm">
|
||||
<div class="text-caption text-grey-7">Payment methods *</div>
|
||||
<q-option-group
|
||||
v-model="formDialog.data.extra.payment_methods"
|
||||
type="checkbox"
|
||||
inline
|
||||
:options="paymentMethodOptions"
|
||||
></q-option-group>
|
||||
<div class="text-caption text-grey-7">
|
||||
Card / fiat checkout goes through the fiat provider configured on
|
||||
this LNbits instance. Untick Lightning for a card-only event.
|
||||
</div>
|
||||
</div>
|
||||
<q-select
|
||||
v-if="
|
||||
formDialog.data.allow_fiat &&
|
||||
acceptsFiat &&
|
||||
['sat', 'sats'].includes(
|
||||
(formDialog.data.currency || '').toLowerCase()
|
||||
)
|
||||
|
|
|
|||
48
tests/test_crud_ticket_email.py
Normal file
48
tests/test_crud_ticket_email.py
Normal file
|
|
@ -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 == ""
|
||||
52
tests/test_frontend_root.py
Normal file
52
tests/test_frontend_root.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models import CreateTicket
|
||||
from ..views_api import _allowed_frontend_origins, _resolve_frontend_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lnbits_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||
monkeypatch.setattr(
|
||||
settings, "lnbits_cors_allowed_origins", ["https://app.example"], raising=False
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"lnbits_custom_frontend_url",
|
||||
"https://Front.Example/login",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
|
||||
def _request(base_url: str = "https://lnbits.example/"):
|
||||
return SimpleNamespace(base_url=base_url)
|
||||
|
||||
|
||||
def test_allowlist_collects_every_configured_origin(lnbits_settings):
|
||||
assert _allowed_frontend_origins() == {
|
||||
"https://lnbits.example",
|
||||
"https://app.example",
|
||||
"https://front.example",
|
||||
}
|
||||
|
||||
|
||||
def test_absent_frontend_url_falls_back_to_the_request_host(lnbits_settings):
|
||||
data = CreateTicket(user_id="u1")
|
||||
assert _resolve_frontend_root(data, _request()) == "https://lnbits.example"
|
||||
|
||||
|
||||
def test_allowed_origin_is_returned_without_trailing_slash(lnbits_settings):
|
||||
data = CreateTicket(user_id="u1", frontend_url="https://app.example/events/")
|
||||
assert _resolve_frontend_root(data, _request()) == "https://app.example/events"
|
||||
|
||||
|
||||
def test_unlisted_origin_is_rejected_loudly(lnbits_settings):
|
||||
data = CreateTicket(user_id="u1", frontend_url="https://evil.example/events")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_resolve_frontend_root(data, _request())
|
||||
assert exc.value.status_code == 400
|
||||
assert "frontend_url" in exc.value.detail
|
||||
110
tests/test_ticket_models.py
Normal file
110
tests/test_ticket_models.py
Normal file
|
|
@ -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"])
|
||||
22
tests/test_ticket_qr.py
Normal file
22
tests/test_ticket_qr.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ..views_api import make_qr_png
|
||||
|
||||
|
||||
def test_make_qr_png_renders_requested_size():
|
||||
img = make_qr_png("ticket://abc123", size=200)
|
||||
assert img.size == (200, 200)
|
||||
|
||||
|
||||
def test_make_qr_png_pastes_a_centred_logo():
|
||||
logo = Image.new("RGBA", (64, 64), (255, 0, 0, 255))
|
||||
img = make_qr_png("ticket://abc123", size=300, logo=logo)
|
||||
assert img.size == (300, 300)
|
||||
# The centre pixel is inside the pasted logo, so it is red — a plain
|
||||
# QR would only ever have black or white there.
|
||||
assert img.getpixel((150, 150))[:3] == (255, 0, 0)
|
||||
out = BytesIO()
|
||||
img.save(out, format="PNG")
|
||||
assert out.getvalue().startswith(b"\x89PNG")
|
||||
235
views_api.py
235
views_api.py
|
|
@ -1,8 +1,13 @@
|
|||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pyqrcode # type: ignore[import-untyped]
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
|
|
@ -12,18 +17,19 @@ from fastapi import (
|
|||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Account, User, WalletTypeInfo
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services import create_payment_request
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.decorators import (
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
fiat_amount_as_satoshis,
|
||||
|
|
@ -31,6 +37,8 @@ from lnbits.utils.exchange_rates import (
|
|||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
from loguru import logger
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .crud import (
|
||||
create_event,
|
||||
|
|
@ -64,6 +72,8 @@ from .models import (
|
|||
PublicTicket,
|
||||
Ticket,
|
||||
TicketPaymentRequest,
|
||||
TicketResendResult,
|
||||
effective_payment_methods,
|
||||
)
|
||||
from .nostr_hooks import publish_or_delete_nostr_event
|
||||
from .services import (
|
||||
|
|
@ -76,6 +86,7 @@ from .tasks import deregister_payment_listener, register_payment_listener
|
|||
|
||||
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||
qr_api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
|
||||
def _is_fiat_currency(currency: str | None) -> bool:
|
||||
|
|
@ -513,6 +524,136 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
|
|||
return ticket
|
||||
|
||||
|
||||
def _origin(url: str | None) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
parts = urlsplit(url.strip())
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return None
|
||||
return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
|
||||
|
||||
|
||||
def _allowed_frontend_origins() -> set[str]:
|
||||
"""Origins a buyer-side client may name in `CreateTicket.frontend_url`.
|
||||
|
||||
The CORS allow-list is literally "which web apps may talk to this
|
||||
LNbits", so it is the natural allow-list for "which web apps may be
|
||||
linked from a ticket email". The LNbits host itself and the configured
|
||||
custom frontend are always fine.
|
||||
"""
|
||||
origins: set[str] = set()
|
||||
for candidate in [
|
||||
*getattr(settings, "lnbits_cors_allowed_origins", []),
|
||||
settings.lnbits_baseurl,
|
||||
getattr(settings, "lnbits_custom_frontend_url", None),
|
||||
]:
|
||||
origin = _origin(candidate)
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
return origins
|
||||
|
||||
|
||||
def _resolve_frontend_root(data: CreateTicket, request: Request) -> str:
|
||||
"""Root under which `/events/{event_id}` and `/events/ticket/{ticket_id}`
|
||||
resolve for the buyer — the calling app when it says so, else the LNbits
|
||||
host (the extension's own Quasar pages)."""
|
||||
if not data.frontend_url:
|
||||
return str(request.base_url).rstrip("/")
|
||||
origin = _origin(data.frontend_url)
|
||||
if not origin or origin not in _allowed_frontend_origins():
|
||||
# Fail loud rather than silently falling back: a wrong root means
|
||||
# the buyer is returned to (and emailed a link into) the wrong app.
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="frontend_url origin is not allowed.",
|
||||
)
|
||||
return data.frontend_url.rstrip("/")
|
||||
|
||||
|
||||
_qr_logo_cache: dict[str, Image.Image | None] = {}
|
||||
|
||||
|
||||
async def _load_qr_logo() -> Image.Image | None:
|
||||
"""LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
|
||||
|
||||
Local `/static/...` values resolve inside the LNbits package; absolute
|
||||
URLs are fetched once. Any failure just yields a plain QR.
|
||||
"""
|
||||
source = (settings.lnbits_qr_logo or "").strip()
|
||||
if not source:
|
||||
return None
|
||||
if source in _qr_logo_cache:
|
||||
return _qr_logo_cache[source]
|
||||
logo: Image.Image | None = None
|
||||
try:
|
||||
if source.startswith(("http://", "https://")):
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
resp = await client.get(source)
|
||||
resp.raise_for_status()
|
||||
logo = Image.open(BytesIO(resp.content)).convert("RGBA")
|
||||
else:
|
||||
local = Path(settings.lnbits_path) / source.lstrip("/")
|
||||
if local.is_file():
|
||||
logo = Image.open(local).convert("RGBA")
|
||||
except Exception as exc:
|
||||
logger.warning(f"QR logo '{source}' unavailable: {exc}")
|
||||
logo = None
|
||||
_qr_logo_cache[source] = logo
|
||||
return logo
|
||||
|
||||
|
||||
def make_qr_png(
|
||||
data: str,
|
||||
size: int = 235,
|
||||
border: int = 4,
|
||||
logo: Image.Image | None = None,
|
||||
) -> Image.Image:
|
||||
"""Render `data` as a QR image (upstream v1.6.8 shape). With `logo`, the
|
||||
code is built at error-correction level H and the logo is pasted in the
|
||||
centre on a white pad at ≤ 20 % of the width — the same look LNbits'
|
||||
client-side `lnbits-qrcode` component produces."""
|
||||
qr = pyqrcode.create(data, error="H" if logo is not None else "M")
|
||||
matrix = qr.code
|
||||
modules = len(matrix)
|
||||
|
||||
total_modules = modules + border * 2
|
||||
box_size = max(1, size // total_modules)
|
||||
img_size = total_modules * box_size
|
||||
|
||||
img = Image.new("RGBA", (img_size, img_size), "white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for y, row in enumerate(matrix):
|
||||
for x, cell in enumerate(row):
|
||||
if cell:
|
||||
x0 = (x + border) * box_size
|
||||
y0 = (y + border) * box_size
|
||||
draw.rectangle(
|
||||
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
|
||||
fill="black",
|
||||
)
|
||||
|
||||
if img_size != size:
|
||||
img = img.resize((size, size), Image.Resampling.NEAREST)
|
||||
|
||||
if logo is not None:
|
||||
logo_size = max(8, int(size * 0.2))
|
||||
pad = max(2, logo_size // 8)
|
||||
scaled = logo.copy()
|
||||
scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
|
||||
plate = Image.new(
|
||||
"RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
|
||||
)
|
||||
plate.paste(scaled, (pad, pad), scaled)
|
||||
img.paste(
|
||||
plate,
|
||||
((size - plate.width) // 2, (size - plate.height) // 2),
|
||||
plate,
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
async def _issue_free_tickets(
|
||||
*,
|
||||
event: Event,
|
||||
|
|
@ -522,7 +663,7 @@ async def _issue_free_tickets(
|
|||
user_id: str | None,
|
||||
promo_code: str | None,
|
||||
nostr_identifier: str | None,
|
||||
request: Request,
|
||||
frontend_root: str,
|
||||
) -> TicketPaymentRequest:
|
||||
"""Issue `quantity` free tickets without minting an invoice.
|
||||
|
||||
|
|
@ -552,7 +693,7 @@ async def _issue_free_tickets(
|
|||
extra={
|
||||
"applied_promo_code": promo_code,
|
||||
"nostr_identifier": nostr_identifier,
|
||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
||||
"ticket_base_url": frontend_root,
|
||||
"sats_paid": 0,
|
||||
},
|
||||
)
|
||||
|
|
@ -588,7 +729,9 @@ async def api_ticket_create(
|
|||
quantity = data.quantity
|
||||
if event.amount_tickets > 0:
|
||||
if event.sold >= event.amount_tickets:
|
||||
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.GONE, detail="Event is sold out."
|
||||
)
|
||||
remaining = event.amount_tickets - event.sold
|
||||
if quantity > remaining:
|
||||
raise HTTPException(
|
||||
|
|
@ -618,6 +761,7 @@ async def api_ticket_create(
|
|||
) from exc
|
||||
unit_price = event.price_per_ticket
|
||||
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||
frontend_root = _resolve_frontend_root(data, request)
|
||||
|
||||
if promo_code:
|
||||
# check if promo_code exists in event.extra.promo_codes
|
||||
|
|
@ -645,13 +789,16 @@ async def api_ticket_create(
|
|||
user_id=user_id,
|
||||
promo_code=promo_code,
|
||||
nostr_identifier=nostr_identifier,
|
||||
request=request,
|
||||
frontend_root=frontend_root,
|
||||
)
|
||||
|
||||
if payment_method == "fiat" and not event.allow_fiat:
|
||||
# Organizer-controlled rails (extra.payment_methods; legacy events fall
|
||||
# back to Lightning + fiat-if-allow_fiat). Checked after the free path
|
||||
# because a free claim charges nothing on any rail.
|
||||
if payment_method not in effective_payment_methods(event):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Fiat payments are not enabled for this event.",
|
||||
detail="Payment method not enabled for this event.",
|
||||
)
|
||||
|
||||
if _is_fiat_currency(event.currency):
|
||||
|
|
@ -692,6 +839,36 @@ async def api_ticket_create(
|
|||
else:
|
||||
invoice_unit = "sat"
|
||||
|
||||
# Each row gets a fresh urlsafe_short_hash id so single- and
|
||||
# multi-ticket purchases stay shape-consistent — every scannable
|
||||
# ticket id is a short hash, never the long bolt11 payment_hash.
|
||||
# The shared `payment_hash` column is the join key for invoice
|
||||
# lookup (poll endpoint, ws notifier, set_ticket_paid loop). Ids are
|
||||
# minted BEFORE the invoice so the fiat success URL can carry them
|
||||
# (the payment_hash only exists after `create_payment_request`).
|
||||
ticket_ids: list[str] = [urlsafe_short_hash() for _ in range(quantity)]
|
||||
|
||||
if payment_method == "fiat":
|
||||
# Parameterise the provider's hosted checkout (consumed by
|
||||
# lnbits/fiat/stripe.py `StripeCheckoutOptions`): bring the buyer
|
||||
# back to the app they came from, lock the email they gave us, and
|
||||
# label the line item with the event rather than the raw memo.
|
||||
ticket_label = "ticket" if quantity == 1 else "tickets"
|
||||
extra["checkout"] = {
|
||||
"success_url": (
|
||||
f"{frontend_root}/events/{event.id}"
|
||||
f"?checkout=success&tickets={','.join(ticket_ids)}"
|
||||
),
|
||||
"cancel_url": f"{frontend_root}/events/{event.id}?checkout=cancelled",
|
||||
"customer_email": email,
|
||||
"line_item_name": f"{event.name} — {quantity} {ticket_label}",
|
||||
"metadata": {
|
||||
"event_id": event.id,
|
||||
"quantity": str(quantity),
|
||||
"ticket_ids": ",".join(ticket_ids),
|
||||
},
|
||||
}
|
||||
|
||||
payment = await create_payment_request(
|
||||
wallet_id=event.wallet,
|
||||
invoice_data=CreateInvoice(
|
||||
|
|
@ -703,15 +880,8 @@ async def api_ticket_create(
|
|||
extra=extra,
|
||||
),
|
||||
)
|
||||
# Each row gets a fresh urlsafe_short_hash id so single- and
|
||||
# multi-ticket purchases stay shape-consistent — every scannable
|
||||
# ticket id is a short hash, never the long bolt11 payment_hash.
|
||||
# The shared `payment_hash` column is the join key for invoice
|
||||
# lookup (poll endpoint, ws notifier, set_ticket_paid loop).
|
||||
ticket_ids: list[str] = []
|
||||
sats_per_ticket = payment.sat // quantity if quantity else payment.sat
|
||||
for _ in range(quantity):
|
||||
row_id = urlsafe_short_hash()
|
||||
for row_id in ticket_ids:
|
||||
await create_ticket(
|
||||
payment_hash=payment.payment_hash,
|
||||
wallet=event.wallet,
|
||||
|
|
@ -724,11 +894,10 @@ async def api_ticket_create(
|
|||
"applied_promo_code": promo_code,
|
||||
"refund_address": refund_address,
|
||||
"nostr_identifier": nostr_identifier,
|
||||
"ticket_base_url": str(request.base_url).rstrip("/"),
|
||||
"ticket_base_url": frontend_root,
|
||||
"sats_paid": sats_per_ticket,
|
||||
},
|
||||
)
|
||||
ticket_ids.append(row_id)
|
||||
|
||||
return TicketPaymentRequest(
|
||||
payment_hash=payment.payment_hash,
|
||||
|
|
@ -824,10 +993,10 @@ async def api_ticket_delete(
|
|||
await delete_ticket(ticket_id)
|
||||
|
||||
|
||||
@tickets_api_router.post("/{ticket_id}/resend-email")
|
||||
@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
|
||||
async def api_ticket_resend_email(
|
||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> Ticket:
|
||||
) -> TicketResendResult:
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
|
|
@ -959,3 +1128,31 @@ async def api_event_ticket_stats(
|
|||
for t in paid_tickets
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
|
||||
async def api_ticket_qr(ticket_id: str):
|
||||
"""PNG of the ticket's scan payload (`ticket://<id>`), branded with the
|
||||
instance QR logo. Anonymous by design — it is what the ticket email
|
||||
embeds — and the id is the same bearer token the ticket page exposes.
|
||||
Port of upstream v1.6.8 without ticket-image compositing."""
|
||||
ticket = await get_ticket(ticket_id)
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||
)
|
||||
|
||||
logo = await _load_qr_logo()
|
||||
image = make_qr_png(f"ticket://{ticket_id}", size=300, logo=logo)
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue