2026-08-04 14:18:22 +02:00
|
|
|
"""PNG QR codes for PLM addresses.
|
|
|
|
|
|
|
|
|
|
Deliberately unauthenticated: the dashboard renders it with a plain `<img>`
|
|
|
|
|
tag, which cannot carry the bearer token, and the payload is an address the
|
|
|
|
|
caller already has. What the endpoint must not be is a free CPU amplifier
|
|
|
|
|
(B-67), so two things bound the work an anonymous caller can ask for:
|
|
|
|
|
|
|
|
|
|
- the address is validated for real (bech32 checksum + PLM HRP) via
|
|
|
|
|
`is_valid_plm_address`, the same check withdrawals and the admin
|
|
|
|
|
`fee_address` validator use, instead of a shape-only regex that happily
|
|
|
|
|
rendered a QR for any `plm1`-prefixed junk string;
|
|
|
|
|
- the render itself is memoized per address and pushed off the event loop, so
|
|
|
|
|
a repeat request costs a dict lookup and a first one never blocks the
|
|
|
|
|
scheduler, the listener or any other request.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-07-21 10:51:56 +02:00
|
|
|
import io
|
2026-08-04 14:18:22 +02:00
|
|
|
from functools import lru_cache
|
2026-07-21 10:51:56 +02:00
|
|
|
|
|
|
|
|
import qrcode
|
2026-08-04 14:18:22 +02:00
|
|
|
from fastapi import APIRouter
|
|
|
|
|
from fastapi.concurrency import run_in_threadpool
|
2026-07-21 10:51:56 +02:00
|
|
|
from fastapi.responses import Response
|
|
|
|
|
|
2026-08-04 14:18:22 +02:00
|
|
|
from app.api.errors import http_error
|
|
|
|
|
from app.wallet.address import is_valid_plm_address
|
|
|
|
|
|
2026-07-21 10:51:56 +02:00
|
|
|
router = APIRouter(tags=["qr"])
|
|
|
|
|
|
2026-08-04 14:18:22 +02:00
|
|
|
# Long enough for any bech32 address, short enough that a multi-kilobyte path
|
|
|
|
|
# is rejected before embit ever looks at it.
|
|
|
|
|
_MAX_ADDRESS_LENGTH = 100
|
|
|
|
|
|
|
|
|
|
# Bounded on purpose: valid addresses are cheap to generate, so an unbounded
|
|
|
|
|
# cache would just move the amplification from CPU to memory.
|
|
|
|
|
_CACHE_SIZE = 512
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@lru_cache(maxsize=_CACHE_SIZE)
|
|
|
|
|
def _render_png(address: str) -> bytes:
|
|
|
|
|
image = qrcode.make(address)
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
image.save(buf, format="PNG")
|
|
|
|
|
return buf.getvalue()
|
2026-07-21 10:51:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/qr/{address}")
|
|
|
|
|
async def address_qr(address: str) -> Response:
|
2026-08-04 14:18:22 +02:00
|
|
|
if len(address) > _MAX_ADDRESS_LENGTH or not is_valid_plm_address(address):
|
|
|
|
|
raise http_error(400, "invalid_address", "not a valid PLM bech32 address")
|
2026-07-21 10:51:56 +02:00
|
|
|
|
2026-08-04 14:18:22 +02:00
|
|
|
png = await run_in_threadpool(_render_png, address)
|
|
|
|
|
# An address' QR never changes; let the browser stop asking for it.
|
|
|
|
|
return Response(
|
|
|
|
|
content=png,
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
headers={"Cache-Control": "private, max-age=86400, immutable"},
|
|
|
|
|
)
|