diff --git a/README.md b/README.md index cd02134..43ab768 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,10 @@ Events includes a shareable ticket scanner, which can be used to register attend - **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 (links, no images) with the **ticket card** - attached — a self-describing PNG (site, event, when, where, QR with the - instance logo, name on ticket, ticket id) also served at - `GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at - `GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a - From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email` - returns a `TicketResendResult` with per-channel outcome. +- **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 diff --git a/config.json b/config.json index 68e908a..46be592 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.10", + "version": "1.6.1-aio.9", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index 93e6c2f..539fbef 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,16 +4,15 @@ 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 | -| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| 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 | +| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 | diff --git a/qr.py b/qr.py deleted file mode 100644 index 79a693f..0000000 --- a/qr.py +++ /dev/null @@ -1,254 +0,0 @@ -"""QR + ticket-card rendering shared by the API and the mailer. - -`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the -instance QR logo pasted in the centre; `render_ticket_card` wraps it in a -self-describing card (event, when, where, name, ticket id) so the PNG a -buyer saves from the email still says what it is for. -""" - -from __future__ import annotations - -import re -from datetime import datetime -from io import BytesIO -from pathlib import Path - -import httpx -import pyqrcode # type: ignore[import-untyped] -from lnbits.settings import settings -from loguru import logger -from PIL import Image, ImageDraw, ImageFont - -from .models import Event, Ticket - -_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. 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 - - -def _parse_iso(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value) - except ValueError: - try: - return datetime.strptime(value[:10], "%Y-%m-%d") - except ValueError: - return None - - -def format_event_when(event: Event) -> str: - """'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw - strings when they do not parse.""" - start = _parse_iso(event.event_start_date) - end = _parse_iso(event.event_end_date) - if not start: - return event.event_start_date or "" - has_time = "T" in (event.event_start_date or "") - day = start.strftime("%a %d %b %Y") - if not end or end == start: - return f"{day}, {start.strftime('%H:%M')}" if has_time else day - if end.date() == start.date(): - if has_time: - return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}" - return day - end_day = end.strftime("%a %d %b %Y") - if has_time: - return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}" - return f"{day} - {end_day}" - - -def ticket_card_filename(ticket: Ticket, event: Event) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event" - return f"ticket-{slug}-{ticket.id[:8]}.png" - - -_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts" - - -def _font( - size: int, bold: bool = False -) -> ImageFont.ImageFont | ImageFont.FreeTypeFont: - """DejaVu Sans shipped with the extension (full Latin coverage — the - Pillow-bundled default lacks accented glyphs, so 'Château' would render - as tofu); Pillow's default is the fallback.""" - path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf") - try: - return ImageFont.truetype(str(path), size) - except OSError: - try: - return ImageFont.load_default(size=size) - except Exception: # very old Pillow: bitmap default only - return ImageFont.load_default() - - -def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]: - lines: list[str] = [] - for paragraph in text.split("\n"): - words = paragraph.split() - line = "" - for word in words: - candidate = f"{line} {word}".strip() - if draw.textlength(candidate, font=font) <= max_width or not line: - line = candidate - else: - lines.append(line) - line = word - lines.append(line) - return lines - - -def render_ticket_card( - ticket: Ticket, - event: Event, - *, - logo: Image.Image | None = None, - site_title: str | None = None, - width: int = 800, -) -> Image.Image: - """A self-describing ticket: header (site), event name, when/where, the - QR, then name on ticket + ticket id + door instruction.""" - pad = 48 - inner = width - 2 * pad - title_font = _font(40, bold=True) - body_font = _font(28) - small_font = _font(22) - mono_font = _font(24) - - # Measure first: the card grows with the wrapped title. - probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white")) - title_lines = _wrap(probe, event.name, title_font, inner) - when = format_event_when(event) - meta_lines = [when] if when else [] - if event.location: - meta_lines += _wrap(probe, event.location, body_font, inner) - qr_size = min(inner, 560) - detail_lines = [] - if ticket.name: - detail_lines.append(f"Name: {ticket.name}") - detail_lines.append(f"Ticket {ticket.id}") - - y = pad - y += 30 + 16 # site title line - y += len(title_lines) * 52 + 12 - y += len(meta_lines) * 36 + 28 - qr_y = y - y += qr_size + 28 - y += len(detail_lines) * 36 + 12 - y += 30 + pad # footer - height = y - - img = Image.new("RGB", (width, height), "white") - draw = ImageDraw.Draw(img) - grey = (110, 110, 110) - black = (20, 20, 20) - - y = pad - draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font) - y += 30 + 16 - for line in title_lines: - draw.text((pad, y), line, fill=black, font=title_font) - y += 52 - y += 12 - for line in meta_lines: - draw.text((pad, y), line, fill=black, font=body_font) - y += 36 - y += 28 - - qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB") - img.paste(qr, ((width - qr_size) // 2, qr_y)) - y = qr_y + qr_size + 28 - - for line in detail_lines: - font = mono_font if line.startswith("Ticket ") else body_font - draw.text((pad, y), line, fill=black, font=font) - y += 36 - y += 12 - draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font) - return img - - -def image_png_bytes(img: Image.Image) -> bytes: - out = BytesIO() - img.save(out, format="PNG") - return out.getvalue() diff --git a/services.py b/services.py index 9e6d1ce..8476798 100644 --- a/services.py +++ b/services.py @@ -1,10 +1,8 @@ from __future__ import annotations import asyncio -import re import smtplib from asyncio.tasks import create_task -from email.mime.image import MIMEImage from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr, formatdate, make_msgid @@ -28,13 +26,6 @@ from .crud import ( ) from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .nostr_hooks import publish_or_delete_nostr_event -from .qr import ( - format_event_when, - image_png_bytes, - load_qr_logo, - render_ticket_card, - ticket_card_filename, -) DEFAULT_NOSTR_RELAYS = [ "wss://relay.damus.io", @@ -127,15 +118,17 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str def _ticket_details(ticket: Ticket, event: Event) -> str: """Human-readable ticket facts for the email body. Also what keeps the mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY).""" - lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"] + when = event.event_start_date + if event.event_end_date and event.event_end_date != event.event_start_date: + when = f"{when} to {event.event_end_date}" + lines = [f"Event: {event.name}", f"When: {when}"] if event.location: lines.append(f"Where: {event.location}") if ticket.name: lines.append(f"Name on ticket: {ticket.name}") lines.append(f"Ticket ID: {ticket.id}") lines.append( - "Your ticket (with its QR code) is attached to this email — save it " - "or open the link above on your phone, and show the QR code at the " + "Show the QR code below (or open the link above on your phone) at the " "door to be scanned in." ) return "\n".join(lines) @@ -149,18 +142,14 @@ def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: - """HTML twin of the text part. Deliberately no : the card travels - as an attachment (renders inline in most clients, works offline, and - keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become - links.""" text_message = _ticket_delivery_message(ticket, event, base_message) - html = escape(text_message) - html = re.sub( - r"(https?://[^\s<]+)", - lambda m: f'{m.group(1)}', - html, + 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

' ) - return f"

{html.replace(chr(10), '
')}

" def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: @@ -206,20 +195,8 @@ async def _deliver_ticket_notifications( if result.email.attempted: try: assert ticket.email - card = render_ticket_card( - ticket, - event, - logo=await load_qr_logo(), - site_title=settings.lnbits_site_title, - ) await _send_ticket_email_notification( - [ticket.email], - text_message, - subject, - html_message, - attachments=[ - (ticket_card_filename(ticket, event), image_png_bytes(card)) - ], + [ticket.email], text_message, subject, html_message ) ticket.extra.email_notification_sent = True result.email.sent = True @@ -250,7 +227,6 @@ async def _send_ticket_email_notification( message: str, subject: str, html_message: str | None = None, - attachments: list[tuple[str, bytes]] | None = None, ) -> None: """Multipart (text + HTML) ticket email through the instance SMTP settings. Core's `send_email_notification` is plain-text only, which is @@ -268,9 +244,7 @@ async def _send_ticket_email_notification( if not is_valid_email_address(address): raise ValueError(f"Invalid email address: {address}") - msg = build_ticket_email( - from_email, to_emails, subject, message, html_message, attachments - ) + msg = build_ticket_email(from_email, to_emails, subject, message, html_message) username = settings.lnbits_email_notifications_username or from_email await asyncio.to_thread( _smtp_send, @@ -290,34 +264,22 @@ def build_ticket_email( subject: str, message: str, html_message: str | None = None, - attachments: list[tuple[str, bytes]] | None = None, ) -> MIMEMultipart: - """Assemble the ticket email: text + HTML alternatives, PNG attachments - (the ticket card), and the headers receivers score on — a Date and a - Message-ID (their absence is what SpamAssassin's MISSING_DATE / - MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and - a display name on From so the sender is not a bare address.""" - body = MIMEMultipart("alternative") - body.attach(MIMEText(message, "plain")) - if html_message: - body.attach(MIMEText(html_message, "html")) - - if attachments: - msg = MIMEMultipart("mixed") - msg.attach(body) - for filename, data in attachments: - part = MIMEImage(data, _subtype="png") - part.add_header("Content-Disposition", "attachment", filename=filename) - msg.attach(part) - else: - msg = body - + """Assemble the multipart ticket email with the headers receivers score + on: a Date and a Message-ID (their absence is what SpamAssassin's + MISSING_DATE / MISSING_MID flag, and what Gmail/Outlook read as + machine-generated), and a display name on From so the sender is not a + bare address.""" + msg = MIMEMultipart("alternative") sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" msg["From"] = formataddr((sender_name, from_email)) msg["To"] = ", ".join(to_emails) msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) + msg.attach(MIMEText(message, "plain")) + if html_message: + msg.attach(MIMEText(html_message, "html")) return msg @@ -356,13 +318,11 @@ def _ticket_url(ticket: Ticket) -> str: def _ticket_image_url(ticket: Ticket) -> str: - """The ticket card 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 + """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/ticket-card/{ticket.id}" - ) + return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}" async def refund_tickets(event_id: str): diff --git a/static/fonts/DejaVuSans-Bold.ttf b/static/fonts/DejaVuSans-Bold.ttf deleted file mode 100644 index 3710f89..0000000 Binary files a/static/fonts/DejaVuSans-Bold.ttf and /dev/null differ diff --git a/static/fonts/DejaVuSans.ttf b/static/fonts/DejaVuSans.ttf deleted file mode 100644 index 1a12606..0000000 Binary files a/static/fonts/DejaVuSans.ttf and /dev/null differ diff --git a/static/fonts/LICENSE-DejaVu.txt b/static/fonts/LICENSE-DejaVu.txt deleted file mode 100644 index df52c17..0000000 --- a/static/fonts/LICENSE-DejaVu.txt +++ /dev/null @@ -1,187 +0,0 @@ -Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. -Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) - - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr. - -TeX Gyre DJV Math ------------------ -Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. - -Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski -(on behalf of TeX users groups) are in public domain. - -Letters imported from Euler Fraktur from AMSfonts are (c) American -Mathematical Society (see below). -Bitstream Vera Fonts Copyright -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera -is a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license (“Fonts”) and associated -documentation -files (the “Font Software”), to reproduce and distribute the Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, -and/or sell copies of the Font Software, and to permit persons to whom -the Font Software is furnished to do so, subject to the following -conditions: - -The above copyright and trademark notices and this permission notice -shall be -included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional -glyphs or characters may be added to the Fonts, only if the fonts are -renamed -to names not containing either the words “Bitstream” or the word “Vera”. - -This License becomes null and void to the extent applicable to Fonts or -Font Software -that has been modified and is distributed under the “Bitstream Vera” -names. - -The Font Software may be sold as part of a larger software package but -no copy -of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION -BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, -SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN -ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR -INABILITY TO USE -THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. -Except as contained in this notice, the names of GNOME, the GNOME -Foundation, -and Bitstream Inc., shall not be used in advertising or otherwise to promote -the sale, use or other dealings in this Font Software without prior written -authorization from the GNOME Foundation or Bitstream Inc., respectively. -For further information, contact: fonts at gnome dot org. - -AMSFonts (v. 2.2) copyright - -The PostScript Type 1 implementation of the AMSFonts produced by and -previously distributed by Blue Sky Research and Y&Y, Inc. are now freely -available for general use. This has been accomplished through the -cooperation -of a consortium of scientific publishers with Blue Sky Research and Y&Y. -Members of this consortium include: - -Elsevier Science IBM Corporation Society for Industrial and Applied -Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) - -In order to assure the authenticity of these fonts, copyright will be -held by -the American Mathematical Society. This is not meant to restrict in any way -the legitimate use of the fonts, such as (but not limited to) electronic -distribution of documents containing these fonts, inclusion of these fonts -into other public domain or commercial font collections or computer -applications, use of the outline data to create derivative fonts and/or -faces, etc. However, the AMS does require that the AMS copyright notice be -removed from any derivative versions of the fonts which have been altered in -any way. In addition, to ensure the fidelity of TeX documents using Computer -Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, -has requested that any alterations which yield different font metrics be -given a different name. - -$Id$ diff --git a/tests/test_ticket_email.py b/tests/test_ticket_email.py index f9b6c96..f33d670 100644 --- a/tests/test_ticket_email.py +++ b/tests/test_ticket_email.py @@ -53,25 +53,6 @@ def test_email_carries_date_message_id_and_display_name(monkeypatch): assert parts == ["text/plain", "text/html"] -def test_email_attaches_the_ticket_card(monkeypatch): - monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!") - png = b"\x89PNG\r\n\x1a\n" + b"0" * 32 - msg = build_ticket_email( - "tickets@example.org", - ["ada@example.com"], - "Subj", - "text", - "

html

", - attachments=[("ticket-test-8auNuuaB.png", png)], - ) - assert msg.get_content_type() == "multipart/mixed" - body, attachment = msg.get_payload() - assert body.get_content_type() == "multipart/alternative" - assert attachment.get_content_type() == "image/png" - assert attachment.get_filename() == "ticket-test-8auNuuaB.png" - assert attachment.get_payload(decode=True) == png - - def test_payload_includes_event_details_and_qr(monkeypatch): monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/") subject, text, html = _ticket_notification_payload(_ticket(), _event()) @@ -84,6 +65,5 @@ def test_payload_includes_event_details_and_qr(monkeypatch): "at the door", ): assert needle in text - assert "/events/api/v1/ticket-card/tkt1" in text - assert "' in html + assert "/events/api/v1/qr/tkt1" in text + assert " 800 - # QR area is centred; its finder pattern is black - assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255)) diff --git a/views_api.py b/views_api.py index ac0b0ba..8ae7b86 100644 --- a/views_api.py +++ b/views_api.py @@ -2,9 +2,12 @@ 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, @@ -34,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, @@ -71,13 +76,6 @@ from .models import ( effective_payment_methods, ) from .nostr_hooks import publish_or_delete_nostr_event -from .qr import ( - image_png_bytes, - load_qr_logo, - make_qr_png, - render_ticket_card, - ticket_card_filename, -) from .services import ( refund_tickets, resend_ticket_email_notification, @@ -572,6 +570,90 @@ def _resolve_frontend_root(data: CreateTicket, request: Request) -> str: 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, @@ -1060,10 +1142,13 @@ async def api_ticket_qr(ticket_id: str): status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." ) - logo = await load_qr_logo() + 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( - BytesIO(image_png_bytes(image)), + output, media_type="image/png", headers={ "Cache-Control": "no-cache, no-store, must-revalidate", @@ -1071,32 +1156,3 @@ async def api_ticket_qr(ticket_id: str): "Expires": "0", }, ) - - -@qr_api_router.get("/ticket-card/{ticket_id}", response_class=StreamingResponse) -async def api_ticket_card(ticket_id: str): - """Self-describing ticket PNG (event, when, where, QR, name, id) — the - same image the ticket email attaches. Anonymous like the QR endpoint.""" - ticket = await get_ticket(ticket_id) - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." - ) - event = await get_event(ticket.event) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - logo = await load_qr_logo() - card = render_ticket_card( - ticket, event, logo=logo, site_title=settings.lnbits_site_title - ) - filename = ticket_card_filename(ticket, event) - return StreamingResponse( - BytesIO(image_png_bytes(card)), - media_type="image/png", - headers={ - "Content-Disposition": f'inline; filename="{filename}"', - "Cache-Control": "no-cache, no-store, must-revalidate", - }, - )