From ad9fd4e4add927e799af25700c8db327e6108cc1 Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Thu, 16 Feb 2023 10:03:33 +0000 Subject: [PATCH] Add files via upload --- README.md | 27 +++ __init__.py | 35 ++++ config.json | 10 + crud.py | 95 +++++++++ lnurl.py | 106 ++++++++++ migrations.py | 148 ++++++++++++++ models.py | 75 +++++++ static/image/lnurl-pay.png | Bin 0 -> 12984 bytes static/js/index.js | 264 ++++++++++++++++++++++++ tasks.py | 79 ++++++++ templates/lnurlp/_api_docs.html | 138 +++++++++++++ templates/lnurlp/_lnurl.html | 31 +++ templates/lnurlp/display.html | 54 +++++ templates/lnurlp/index.html | 345 ++++++++++++++++++++++++++++++++ templates/lnurlp/print_qr.html | 27 +++ views.py | 43 ++++ views_api.py | 168 ++++++++++++++++ 17 files changed, 1645 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 lnurl.py create mode 100644 migrations.py create mode 100644 models.py create mode 100644 static/image/lnurl-pay.png create mode 100644 static/js/index.js create mode 100644 tasks.py create mode 100644 templates/lnurlp/_api_docs.html create mode 100644 templates/lnurlp/_lnurl.html create mode 100644 templates/lnurlp/display.html create mode 100644 templates/lnurlp/index.html create mode 100644 templates/lnurlp/print_qr.html create mode 100644 views.py create mode 100644 views_api.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..0832bfb --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# LNURLp + +## Create a static QR code people can use to pay over Lightning Network + +LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +1. Create an LNURLp (New Pay link)\ + ![create lnurlp](https://i.imgur.com/rhUBJFy.jpg) + + - select your wallets + - make a small description + - enter amount + - if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount + - you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp + - You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post) + - Webhook URL allows to call an URL when the LNURLp is paid + - Success mesage, will send a message back to the user after a successful payment, for example a thank you note + - Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link + +2. Use the shareable link or view the LNURLp you just created\ + ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) + - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ + ![view lnurlp](https://i.imgur.com/4n41S7T.jpg) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f5ea0cd --- /dev/null +++ b/__init__.py @@ -0,0 +1,35 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_lnurlp") + +lnurlp_static_files = [ + { + "path": "/lnurlp/static", + "app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]), + "name": "lnurlp_static", + } +] + +lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"]) + + +def lnurlp_renderer(): + return template_renderer(["lnbits/extensions/lnurlp/templates"]) + + +from .lnurl import * # noqa: F401,F403 +from .tasks import wait_for_paid_invoices +from .views import * # noqa: F401,F403 +from .views_api import * # noqa: F401,F403 + + +def lnurlp_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/config.json b/config.json new file mode 100644 index 0000000..d3e046d --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "name": "LNURLp", + "short_description": "Make reusable LNURL pay links", + "tile": "/lnurlp/static/image/lnurl-pay.png", + "contributors": [ + "arcbtc", + "eillarra", + "fiatjaf" + ] +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..4acb4a4 --- /dev/null +++ b/crud.py @@ -0,0 +1,95 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreatePayLinkData, PayLink + + +async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: + link_id = urlsafe_short_hash()[:6] + + result = await db.execute( + """ + INSERT INTO lnurlp.pay_links ( + id, + wallet, + description, + min, + max, + served_meta, + served_pr, + webhook_url, + webhook_headers, + webhook_body, + success_text, + success_url, + comment_chars, + currency, + fiat_base_multiplier + ) + VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + link_id, + wallet_id, + data.description, + data.min, + data.max, + data.webhook_url, + data.webhook_headers, + data.webhook_body, + data.success_text, + data.success_url, + data.comment_chars, + data.currency, + data.fiat_base_multiplier, + ), + ) + assert result + + link = await get_pay_link(link_id) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_pay_link(link_id: str) -> Optional[PayLink]: + row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) + return PayLink.from_row(row) if row else None + + +async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) + ORDER BY Id + """, + (*wallet_ids,), + ) + return [PayLink.from_row(row) for row in rows] + + +async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) + row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) + return PayLink.from_row(row) if row else None + + +async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: + q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) + row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) + return PayLink.from_row(row) if row else None + + +async def delete_pay_link(link_id: int) -> None: + await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,)) diff --git a/lnurl.py b/lnurl.py new file mode 100644 index 0000000..918a5bd --- /dev/null +++ b/lnurl.py @@ -0,0 +1,106 @@ +from http import HTTPStatus + +from fastapi import Request +from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse +from starlette.exceptions import HTTPException + +from lnbits.core.services import create_invoice +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis + +from . import lnurlp_ext +from .crud import increment_pay_link + + +@lnurlp_ext.get( + "/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL) + status_code=HTTPStatus.OK, + name="lnurlp.api_lnurl_response.deprecated", +) +@lnurlp_ext.get( + "/{link_id}", + status_code=HTTPStatus.OK, + name="lnurlp.api_lnurl_response", +) +async def api_lnurl_response(request: Request, link_id): + link = await increment_pay_link(link_id, served_meta=1) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 + + resp = LnurlPayResponse( + callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id), + min_sendable=round(link.min * rate) * 1000, + max_sendable=round(link.max * rate) * 1000, + metadata=link.lnurlpay_metadata, + ) + params = resp.dict() + + if link.comment_chars > 0: + params["commentAllowed"] = link.comment_chars + + return params + + +@lnurlp_ext.get( + "/api/v1/lnurl/cb/{link_id}", + status_code=HTTPStatus.OK, + name="lnurlp.api_lnurl_callback", +) +async def api_lnurl_callback(request: Request, link_id): + link = await increment_pay_link(link_id, served_pr=1) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + min, max = link.min, link.max + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 + if link.currency: + # allow some fluctuation (as the fiat price may have changed between the calls) + min = rate * 995 * link.min + max = rate * 1010 * link.max + else: + min = link.min * 1000 + max = link.max * 1000 + + amount_received = int(request.query_params.get("amount") or 0) + if amount_received < min: + return LnurlErrorResponse( + reason=f"Amount {amount_received} is smaller than minimum {min}." + ).dict() + + elif amount_received > max: + return LnurlErrorResponse( + reason=f"Amount {amount_received} is greater than maximum {max}." + ).dict() + + comment = request.query_params.get("comment") + if len(comment or "") > link.comment_chars: + return LnurlErrorResponse( + reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" + ).dict() + + payment_hash, payment_request = await create_invoice( + wallet_id=link.wallet, + amount=int(amount_received / 1000), + memo=link.description, + unhashed_description=link.lnurlpay_metadata.encode(), + extra={ + "tag": "lnurlp", + "link": link.id, + "comment": comment, + "extra": request.query_params.get("amount"), + }, + ) + + success_action = link.success_action(payment_hash) + if success_action: + resp = LnurlPayActionResponse( + pr=payment_request, success_action=success_action, routes=[] + ) + else: + resp = LnurlPayActionResponse(pr=payment_request, routes=[]) + + return resp.dict() diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..1ec85eb --- /dev/null +++ b/migrations.py @@ -0,0 +1,148 @@ +async def m001_initial(db): + """ + Initial pay table. + """ + await db.execute( + f""" + CREATE TABLE lnurlp.pay_links ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + amount {db.big_int} NOT NULL, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL + ); + """ + ) + + +async def m002_webhooks_and_success_actions(db): + """ + Webhooks and success actions. + """ + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;") + await db.execute( + f""" + CREATE TABLE lnurlp.invoices ( + pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id), + payment_hash TEXT NOT NULL, + webhook_sent INT, -- null means not sent, otherwise store status + expiry INT + ); + """ + ) + + +async def m003_min_max_comment_fiat(db): + """ + Support for min/max amounts, comments and fiat prices that get + converted automatically to satoshis based on some API. + """ + await db.execute( + "ALTER TABLE lnurlp.pay_links ADD COLUMN currency TEXT;" + ) # null = satoshis + await db.execute( + "ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" + ) + await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;") + await db.execute("UPDATE lnurlp.pay_links SET max = min;") + await db.execute("DROP TABLE lnurlp.invoices") + + +async def m004_fiat_base_multiplier(db): + """ + Store the multiplier for fiat prices. We store the price in cents and + remember to multiply by 100 when we use it to convert to Dollars. + """ + await db.execute( + "ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" + ) + + +async def m005_webhook_headers_and_body(db): + """ + Add headers and body to webhooks + """ + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;") + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;") + + +async def m006_redux(db): + """ + Migrate ID column type to string for UUIDs and migrate existing data + """ + # we can simply change the column type for postgres + if db.type != "SQLITE": + await db.execute("ALTER TABLE lnurlp.pay_links ALTER COLUMN id TYPE TEXT;") + else: + # but we have to do this for sqlite + await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old") + await db.execute( + f""" + CREATE TABLE lnurlp.pay_links ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + min {db.big_int} NOT NULL, + max {db.big_int}, + currency TEXT, + fiat_base_multiplier INTEGER DEFAULT 1, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL, + webhook_url TEXT, + success_text TEXT, + success_url TEXT, + comment_chars INTEGER DEFAULT 0, + webhook_headers TEXT, + webhook_body TEXT + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old") + ]: + await db.execute( + """ + INSERT INTO lnurlp.pay_links ( + id, + wallet, + description, + min, + served_meta, + served_pr, + webhook_url, + success_text, + success_url, + currency, + comment_chars, + max, + fiat_base_multiplier, + webhook_headers, + webhook_body + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + row[12], + row[13], + row[14], + ), + ) + + await db.execute("DROP TABLE lnurlp.pay_links_old") diff --git a/models.py b/models.py new file mode 100644 index 0000000..4ee82aa --- /dev/null +++ b/models.py @@ -0,0 +1,75 @@ +import json +from sqlite3 import Row +from typing import Dict, Optional +from urllib.parse import ParseResult, urlparse, urlunparse + +from fastapi.param_functions import Query +from lnurl.types import LnurlPayMetadata +from pydantic import BaseModel +from starlette.requests import Request + +from lnbits.lnurl import encode as lnurl_encode + + +class CreatePayLinkData(BaseModel): + description: str + min: float = Query(1, ge=0.01) + max: float = Query(1, ge=0.01) + currency: str = Query(None) + comment_chars: int = Query(0, ge=0, lt=800) + webhook_url: str = Query(None) + webhook_headers: str = Query(None) + webhook_body: str = Query(None) + success_text: str = Query(None) + success_url: str = Query(None) + fiat_base_multiplier: int = Query(100, ge=1) + + +class PayLink(BaseModel): + id: str + wallet: str + description: str + min: float + served_meta: int + served_pr: int + webhook_url: Optional[str] + webhook_headers: Optional[str] + webhook_body: Optional[str] + success_text: Optional[str] + success_url: Optional[str] + currency: Optional[str] + comment_chars: int + max: float + fiat_base_multiplier: int + + @classmethod + def from_row(cls, row: Row) -> "PayLink": + data = dict(row) + if data["currency"] and data["fiat_base_multiplier"]: + data["min"] /= data["fiat_base_multiplier"] + data["max"] /= data["fiat_base_multiplier"] + return cls(**data) + + def lnurl(self, req: Request) -> str: + url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id) + return lnurl_encode(url) + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) + + def success_action(self, payment_hash: str) -> Optional[Dict]: + if self.success_url: + url: ParseResult = urlparse(self.success_url) + #qs = parse_qs(url.query) + #setattr(qs, "payment_hash", payment_hash) + #url = url._replace(query=urlencode(qs, doseq=True)) + return { + "tag": "url", + "description": self.success_text or "~", + "url": urlunparse(url), + } + elif self.success_text: + return {"tag": "message", "message": self.success_text} + else: + return None diff --git a/static/image/lnurl-pay.png b/static/image/lnurl-pay.png new file mode 100644 index 0000000000000000000000000000000000000000..36af81a734a86592411a06dd4fe68a290b35d2f1 GIT binary patch literal 12984 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 literal 0 HcmV?d00001 diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..c1372be --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,264 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +var locationPath = [ + window.location.protocol, + '//', + window.location.host, + window.location.pathname +].join('') + +var mapPayLink = obj => { + obj._data = _.clone(obj) + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount) + obj.print_url = [locationPath, 'print/', obj.id].join('') + obj.pay_url = [locationPath, 'link/', obj.id].join('') + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + currencies: [], + fiatRates: {}, + checker: null, + payLinks: [], + payLinksTable: { + pagination: { + rowsPerPage: 10 + } + }, + nfcTagWriting: false, + formDialog: { + show: false, + fixedAmount: true, + data: {} + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + methods: { + getPayLinks() { + LNbits.api + .request( + 'GET', + '/lnurlp/api/v1/links?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(response => { + this.payLinks = response.data.map(mapPayLink) + }) + .catch(err => { + clearInterval(this.checker) + LNbits.utils.notifyApiError(err) + }) + }, + closeFormDialog() { + this.resetFormData() + }, + openQrCodeDialog(linkId) { + var link = _.findWhere(this.payLinks, {id: linkId}) + if (link.currency) this.updateFiatRate(link.currency) + + this.qrCodeDialog.data = { + id: link.id, + amount: + (link.min === link.max ? link.min : `${link.min} - ${link.max}`) + + ' ' + + (link.currency || 'sat'), + currency: link.currency, + comments: link.comment_chars + ? `${link.comment_chars} characters` + : 'no', + webhook: link.webhook_url || 'nowhere', + success: + link.success_text || link.success_url + ? 'Display message "' + + link.success_text + + '"' + + (link.success_url ? ' and URL "' + link.success_url + '"' : '') + : 'do nothing', + lnurl: link.lnurl, + pay_url: link.pay_url, + print_url: link.print_url + } + this.qrCodeDialog.show = true + }, + openUpdateDialog(linkId) { + const link = _.findWhere(this.payLinks, {id: linkId}) + if (link.currency) this.updateFiatRate(link.currency) + + this.formDialog.data = _.clone(link._data) + this.formDialog.show = true + this.formDialog.fixedAmount = + this.formDialog.data.min === this.formDialog.data.max + }, + sendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + + if (this.formDialog.fixedAmount) data.max = data.min + if (data.currency === 'satoshis') data.currency = null + if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0 + + if (data.id) { + this.updatePayLink(wallet, data) + } else { + this.createPayLink(wallet, data) + } + }, + resetFormData() { + this.formDialog = { + show: false, + fixedAmount: true, + data: {} + } + }, + updatePayLink(wallet, data) { + let values = _.omit( + _.pick( + data, + 'description', + 'min', + 'max', + 'webhook_url', + 'success_text', + 'success_url', + 'comment_chars', + 'currency' + ), + (value, key) => + (key === 'webhook_url' || + key === 'success_text' || + key === 'success_url') && + (value === null || value === '') + ) + + LNbits.api + .request( + 'PUT', + '/lnurlp/api/v1/links/' + data.id, + wallet.adminkey, + values + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) + this.payLinks.push(mapPayLink(response.data)) + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + createPayLink(wallet, data) { + LNbits.api + .request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data) + .then(response => { + this.getPayLinks() + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deletePayLink(linkId) { + var link = _.findWhere(this.payLinks, {id: linkId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this pay link?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/lnurlp/api/v1/links/' + linkId, + _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + updateFiatRate(currency) { + LNbits.api + .request('GET', '/lnurlp/api/v1/rate/' + currency, null) + .then(response => { + let rates = _.clone(this.fiatRates) + rates[currency] = response.data.rate + this.fiatRates = rates + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + writeNfcTag: async function (lnurl) { + try { + if (typeof NDEFReader == 'undefined') { + throw { + toString: function () { + return 'NFC not supported on this device or browser.' + } + } + } + + const ndef = new NDEFReader() + + this.nfcTagWriting = true + this.$q.notify({ + message: 'Tap your NFC tag to write the LNURL-pay link to it.' + }) + + await ndef.write({ + records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}] + }) + + this.nfcTagWriting = false + this.$q.notify({ + type: 'positive', + message: 'NFC tag written successfully.' + }) + } catch (error) { + this.nfcTagWriting = false + this.$q.notify({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } + } + }, + created() { + if (this.g.user.wallets.length) { + var getPayLinks = this.getPayLinks + getPayLinks() + this.checker = setInterval(() => { + getPayLinks() + }, 20000) + } + LNbits.api + .request('GET', '/lnurlp/api/v1/currencies') + .then(response => { + this.currencies = ['satoshis', ...response.data] + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } +}) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..ea01e04 --- /dev/null +++ b/tasks.py @@ -0,0 +1,79 @@ +import asyncio +import json + +import httpx +from loguru import logger + +from lnbits.core.crud import update_payment_extra +from lnbits.core.models import Payment +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + +from .crud import get_pay_link + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue, get_current_extension_name()) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment): + if payment.extra.get("tag") != "lnurlp": + return + + if payment.extra.get("wh_status"): + # this webhook has already been sent + return + + pay_link = await get_pay_link(payment.extra.get("link", -1)) + if pay_link and pay_link.webhook_url: + async with httpx.AsyncClient() as client: + try: + r: httpx.Response = await client.post( + pay_link.webhook_url, + json={ + "payment_hash": payment.payment_hash, + "payment_request": payment.bolt11, + "amount": payment.amount, + "comment": payment.extra.get("comment"), + "lnurlp": pay_link.id, + "body": json.loads(pay_link.webhook_body) + if pay_link.webhook_body + else "", + }, + headers=json.loads(pay_link.webhook_headers) + if pay_link.webhook_headers + else None, + timeout=40, + ) + await mark_webhook_sent( + payment.payment_hash, + r.status_code, + r.is_success, + r.reason_phrase, + r.text, + ) + except Exception as ex: + logger.error(ex) + await mark_webhook_sent( + payment.payment_hash, -1, False, "Unexpected Error", str(ex) + ) + + +async def mark_webhook_sent( + payment_hash: str, status: int, is_success: bool, reason_phrase="", text="" +) -> None: + + await update_payment_extra( + payment_hash, + { + "wh_status": status, # keep for backwards compability + "wh_success": is_success, + "wh_message": reason_phrase, + "wh_response": text, + }, + ) diff --git a/templates/lnurlp/_api_docs.html b/templates/lnurlp/_api_docs.html new file mode 100644 index 0000000..abb37e9 --- /dev/null +++ b/templates/lnurlp/_api_docs.html @@ -0,0 +1,138 @@ + + + + + + GET /lnurlp/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<pay_link_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key: + {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /lnurlp/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + + POST /lnurlp/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string> "amount": <integer> "max": + <integer> "min": <integer> "comment_chars": + <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d + '{"description": <string>, "amount": <integer>, "max": + <integer>, "min": <integer>, "comment_chars": + <integer>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /lnurlp/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"description": <string>, "amount": <integer>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id> + -d '{"description": <string>, "amount": <integer>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /lnurlp/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/templates/lnurlp/_lnurl.html b/templates/lnurlp/_lnurl.html new file mode 100644 index 0000000..f2ba866 --- /dev/null +++ b/templates/lnurlp/_lnurl.html @@ -0,0 +1,31 @@ + + + +

+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use + lightning-network differently. An LNURL-pay is a link that wallets use + to fetch an invoice from a server on-demand. The link or QR code is + fixed, but each time it is read by a compatible wallet a new QR code is + issued by the service. It can be used to activate machines without them + having to maintain an electronic screen to generate and show invoices + locally, or to sell any predefined good or service automatically. +

+

+ Exploring LNURL and finding use cases, is really helping inform + lightning protocol development, rather than the protocol dictating how + lightning-network should be engaged with. +

+ Check + Awesome LNURL + for further information. +
+
+
diff --git a/templates/lnurlp/display.html b/templates/lnurlp/display.html new file mode 100644 index 0000000..7d44037 --- /dev/null +++ b/templates/lnurlp/display.html @@ -0,0 +1,54 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy LNURL + +
+
+
+
+
+ + +
LNbits LNURL-pay link
+

Use an LNURL compatible bitcoin wallet to pay.

+
+ + + {% include "lnurlp/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/templates/lnurlp/index.html b/templates/lnurlp/index.html new file mode 100644 index 0000000..3fbd344 --- /dev/null +++ b/templates/lnurlp/index.html @@ -0,0 +1,345 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New pay link + + + + + +
+
+
Pay links
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURL-pay extension +
+
+ + + + {% include "lnurlp/_api_docs.html" %} + + {% include "lnurlp/_lnurl.html" %} + + +
+
+ + + + + + + + +
+ + + +
+
+
+ +
+
+ +
+
+ + + + + + + + +
+ Update pay link + Create pay link + Cancel +
+
+
+
+ + + + {% raw %} + + + +

+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{ + fiatRates[qrCodeDialog.data.currency] ? + fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook + }}
+ On success: {{ qrCodeDialog.data.success }}
+

+ {% endraw %} +
+ Copy LNURL + Copy sharable link + + Write to NFC + + Print + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/templates/lnurlp/print_qr.html b/templates/lnurlp/print_qr.html new file mode 100644 index 0000000..5f3129d --- /dev/null +++ b/templates/lnurlp/print_qr.html @@ -0,0 +1,27 @@ +{% extends "print.html" %} {% block page %} +
+
+ +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..c5fa358 --- /dev/null +++ b/views.py @@ -0,0 +1,43 @@ +from http import HTTPStatus + +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import lnurlp_ext, lnurlp_renderer +from .crud import get_pay_link + +templates = Jinja2Templates(directory="templates") + + +@lnurlp_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return lnurlp_renderer().TemplateResponse( + "lnurlp/index.html", {"request": request, "user": user.dict()} + ) + + +@lnurlp_ext.get("/link/{link_id}", response_class=HTMLResponse) +async def display(request: Request, link_id): + link = await get_pay_link(link_id) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + ctx = {"request": request, "lnurl": link.lnurl(req=request)} + return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx) + + +@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse) +async def print_qr(request: Request, link_id): + link = await get_pay_link(link_id) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + ctx = {"request": request, "lnurl": link.lnurl(req=request)} + return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..badaaeb --- /dev/null +++ b/views_api.py @@ -0,0 +1,168 @@ +import json +from http import HTTPStatus + +from fastapi import Depends, Query, Request +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis + +from . import lnurlp_ext +from .crud import ( + create_pay_link, + delete_pay_link, + get_pay_link, + get_pay_links, + update_pay_link, +) +from .models import CreatePayLinkData + + +@lnurlp_ext.get("/api/v1/currencies") +async def api_list_currencies_available(): + return list(currencies.keys()) + + +@lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK) +async def api_links( + req: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + all_wallets: bool = Query(False), +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + try: + return [ + {**link.dict(), "lnurl": link.lnurl(req)} + for link in await get_pay_links(wallet_ids) + ] + + except LnurlInvalidUrl: + raise HTTPException( + status_code=HTTPStatus.UPGRADE_REQUIRED, + detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", + ) + + +@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_retrieve( + r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + link = await get_pay_link(link_id) + + if not link: + raise HTTPException( + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN + ) + + return {**link.dict(), **{"lnurl": link.lnurl(r)}} + + +@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) +@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_create_or_update( + data: CreatePayLinkData, + request: Request, + link_id=None, + wallet: WalletTypeInfo = Depends(get_key_type), +): + + if data.min > data.max: + raise HTTPException( + detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST + ) + + if data.currency is None and ( + round(data.min) != data.min or round(data.max) != data.max or data.min < 1 + ): + raise HTTPException( + detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST + ) + + if data.webhook_headers: + try: + json.loads(data.webhook_headers) + except ValueError: + raise HTTPException( + detail="Invalid JSON in webhook_headers.", + status_code=HTTPStatus.BAD_REQUEST, + ) + + if data.webhook_body: + try: + json.loads(data.webhook_body) + except ValueError: + raise HTTPException( + detail="Invalid JSON in webhook_body.", + status_code=HTTPStatus.BAD_REQUEST, + ) + + # database only allows int4 entries for min and max. For fiat currencies, + # we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents. + if data.currency and data.fiat_base_multiplier: + data.min *= data.fiat_base_multiplier + data.max *= data.fiat_base_multiplier + + if data.success_url is not None and not data.success_url.startswith("https://"): + raise HTTPException( + detail="Success URL must be secure https://...", + status_code=HTTPStatus.BAD_REQUEST, + ) + + if link_id: + link = await get_pay_link(link_id) + + if not link: + raise HTTPException( + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN + ) + + link = await update_pay_link(**data.dict(), link_id=link_id) + else: + link = await create_pay_link(data, wallet_id=wallet.wallet.id) + assert link + return {**link.dict(), "lnurl": link.lnurl(request)} + + +@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)): + link = await get_pay_link(link_id) + + if not link: + raise HTTPException( + detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN + ) + + await delete_pay_link(link_id) + return {"success": True} + + +@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK) +async def api_check_fiat_rate(currency): + try: + rate = await get_fiat_rate_satoshis(currency) + except AssertionError: + rate = None + + return {"rate": rate}