From a6c84ceb6117d14a1352cf8861a65e9e4f4c7863 Mon Sep 17 00:00:00 2001 From: benarc Date: Sat, 16 Apr 2022 11:14:34 +0100 Subject: [PATCH 001/129] Geberic QRcode maker Added generic qrcode maker endpoint extensions can use to make embedable qrcodes --- lnbits/core/views/api.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 0e88b5c85..b147c2124 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -7,6 +7,7 @@ from typing import Dict, List, Optional, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx +import pyqrcode from fastapi import Query, Request, Header from fastapi.exceptions import HTTPException from fastapi.param_functions import Depends @@ -580,3 +581,23 @@ async def api_fiat_as_sats(data: ConversionData): output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_) output["BTC"] = output["sats"] / 100000000 return output + +@withdraw_ext.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) +async def img(request: Request, data): + qr = pyqrcode.create(data) + stream = BytesIO() + qr.svg(stream, scale=3) + stream.seek(0) + + async def _generator(stream: BytesIO): + yield stream.getvalue() + + return StreamingResponse( + _generator(stream), + headers={ + "Content-Type": "image/svg+xml", + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "Expires": "0", + }, + ) From c7322fd071db545e88078d7e1dbc0ca33b68564a Mon Sep 17 00:00:00 2001 From: benarc Date: Sat, 16 Apr 2022 11:15:42 +0100 Subject: [PATCH 002/129] correct route --- lnbits/core/views/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index b147c2124..aca5a4582 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -582,7 +582,7 @@ async def api_fiat_as_sats(data: ConversionData): output["BTC"] = output["sats"] / 100000000 return output -@withdraw_ext.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) +@core_app.get("/api/v1/qrcode/{data}", response_class=StreamingResponse) async def img(request: Request, data): qr = pyqrcode.create(data) stream = BytesIO() From b5703e0dfc43ab9013070a433ca8e41e245ec084 Mon Sep 17 00:00:00 2001 From: benarc Date: Sat, 16 Apr 2022 11:17:03 +0100 Subject: [PATCH 003/129] added dependency --- lnbits/core/views/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index aca5a4582..9cb005fef 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -8,6 +8,7 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx import pyqrcode +from starlette.responses import HTMLResponse, StreamingResponse from fastapi import Query, Request, Header from fastapi.exceptions import HTTPException from fastapi.param_functions import Depends From 829e220096ed7696d5aa87a7cd37c88c6c1ae01a Mon Sep 17 00:00:00 2001 From: benarc Date: Sat, 16 Apr 2022 11:18:27 +0100 Subject: [PATCH 004/129] another dependency --- lnbits/core/views/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 9cb005fef..4123a70b5 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -8,6 +8,7 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx import pyqrcode +from io import BytesIO from starlette.responses import HTMLResponse, StreamingResponse from fastapi import Query, Request, Header from fastapi.exceptions import HTTPException From f43d8ff0c3fef5870b2ad392c5914b021ad15efc Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 20:27:52 +0100 Subject: [PATCH 005/129] init --- lnbits/extensions/scrub/README.md | 27 ++ lnbits/extensions/scrub/__init__.py | 35 ++ lnbits/extensions/scrub/config.json | 8 + lnbits/extensions/scrub/crud.py | 91 +++++ lnbits/extensions/scrub/lnurl.py | 109 ++++++ lnbits/extensions/scrub/migrations.py | 51 +++ lnbits/extensions/scrub/models.py | 64 ++++ lnbits/extensions/scrub/static/js/index.js | 227 +++++++++++++ lnbits/extensions/scrub/tasks.py | 59 ++++ .../scrub/templates/lnurlp/_api_docs.html | 135 ++++++++ .../scrub/templates/lnurlp/_lnurl.html | 28 ++ .../scrub/templates/lnurlp/display.html | 47 +++ .../scrub/templates/lnurlp/index.html | 312 ++++++++++++++++++ .../scrub/templates/lnurlp/print_qr.html | 27 ++ lnbits/extensions/scrub/views.py | 44 +++ lnbits/extensions/scrub/views_api.py | 146 ++++++++ 16 files changed, 1410 insertions(+) create mode 100644 lnbits/extensions/scrub/README.md create mode 100644 lnbits/extensions/scrub/__init__.py create mode 100644 lnbits/extensions/scrub/config.json create mode 100644 lnbits/extensions/scrub/crud.py create mode 100644 lnbits/extensions/scrub/lnurl.py create mode 100644 lnbits/extensions/scrub/migrations.py create mode 100644 lnbits/extensions/scrub/models.py create mode 100644 lnbits/extensions/scrub/static/js/index.js create mode 100644 lnbits/extensions/scrub/tasks.py create mode 100644 lnbits/extensions/scrub/templates/lnurlp/_api_docs.html create mode 100644 lnbits/extensions/scrub/templates/lnurlp/_lnurl.html create mode 100644 lnbits/extensions/scrub/templates/lnurlp/display.html create mode 100644 lnbits/extensions/scrub/templates/lnurlp/index.html create mode 100644 lnbits/extensions/scrub/templates/lnurlp/print_qr.html create mode 100644 lnbits/extensions/scrub/views.py create mode 100644 lnbits/extensions/scrub/views_api.py diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md new file mode 100644 index 000000000..4a5c1c160 --- /dev/null +++ b/lnbits/extensions/scrub/README.md @@ -0,0 +1,27 @@ +# scrub + +## 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 scrub (New Scrub link)\ + ![create scrub](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 scrub + - 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 scrub 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 scrub you just created\ + ![scrub](https://i.imgur.com/C8s1P0Q.jpg) + - you can now open your scrub and copy the LNURL, get the shareable link or print it\ + ![view scrub](https://i.imgur.com/4n41S7T.jpg) diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py new file mode 100644 index 000000000..3d25a097e --- /dev/null +++ b/lnbits/extensions/scrub/__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_scrub") + +scrub_static_files = [ + { + "path": "/scrub/static", + "app": StaticFiles(directory="lnbits/extensions/scrub/static"), + "name": "scrub_static", + } +] + +scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"]) + + +def scrub_renderer(): + return template_renderer(["lnbits/extensions/scrub/templates"]) + + +from .lnurl import * # noqa +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def scrub_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/scrub/config.json b/lnbits/extensions/scrub/config.json new file mode 100644 index 000000000..9045b6583 --- /dev/null +++ b/lnbits/extensions/scrub/config.json @@ -0,0 +1,8 @@ +{ + "name": "scrub", + "short_description": "Pass payments to LNURLp/LNaddress", + "icon": "send", + "contributors": [ + "arcbtc" + ] +} diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py new file mode 100644 index 000000000..df97f9904 --- /dev/null +++ b/lnbits/extensions/scrub/crud.py @@ -0,0 +1,91 @@ +from typing import List, Optional, Union + +from lnbits.db import SQLITE +from . import db +from .models import ScrubLink, CreateScrubLinkData + + +async def create_pay_link(data: CreateScrubLinkData, wallet_id: str) -> ScrubLink: + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + result = await (method)( + f""" + INSERT INTO scrub.pay_links ( + wallet, + description, + min, + max, + served_meta, + served_pr, + webhook_url, + success_text, + success_url, + comment_chars, + currency + ) + VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) + {returning} + """, + ( + wallet_id, + data.description, + data.min, + data.max, + data.webhook_url, + data.success_text, + data.success_url, + data.comment_chars, + data.currency, + ), + ) + if db.type == SQLITE: + link_id = result._result_proxy.lastrowid + else: + link_id = result[0] + + 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: int) -> Optional[ScrubLink]: + row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) + return ScrubLink.from_row(row) if row else None + + +async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM scrub.pay_links WHERE wallet IN ({q}) + ORDER BY Id + """, + (*wallet_ids,), + ) + return [ScrubLink.from_row(row) for row in rows] + + +async def update_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) + row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) + return ScrubLink.from_row(row) if row else None + + +async def increment_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]: + q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + ) + row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) + return ScrubLink.from_row(row) if row else None + + +async def delete_pay_link(link_id: int) -> None: + await db.execute("DELETE FROM scrub.pay_links WHERE id = ?", (link_id,)) diff --git a/lnbits/extensions/scrub/lnurl.py b/lnbits/extensions/scrub/lnurl.py new file mode 100644 index 000000000..6d33479f4 --- /dev/null +++ b/lnbits/extensions/scrub/lnurl.py @@ -0,0 +1,109 @@ +import hashlib +import math +from http import HTTPStatus + +from fastapi import Request +from lnurl import ( # type: ignore + LnurlErrorResponse, + LnurlScrubActionResponse, + LnurlScrubResponse, +) +from starlette.exceptions import HTTPException + +from lnbits.core.services import create_invoice +from lnbits.utils.exchange_rates import get_fiat_rate_satoshis + +from . import scrub_ext +from .crud import increment_pay_link + + +@scrub_ext.get( + "/api/v1/lnurl/{link_id}", + status_code=HTTPStatus.OK, + name="scrub.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="Scrub link does not exist." + ) + + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 + + resp = LnurlScrubResponse( + callback=request.url_for("scrub.api_lnurl_callback", link_id=link.id), + min_sendable=math.ceil(link.min * rate) * 1000, + max_sendable=round(link.max * rate) * 1000, + metadata=link.scrubay_metadata, + ) + params = resp.dict() + + if link.comment_chars > 0: + params["commentAllowed"] = link.comment_chars + + return params + + +@scrub_ext.get( + "/api/v1/lnurl/cb/{link_id}", + status_code=HTTPStatus.OK, + name="scrub.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="Scrub 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, + description_hash=hashlib.sha256( + link.scrubay_metadata.encode("utf-8") + ).digest(), + extra={ + "tag": "scrub", + "link": link.id, + "comment": comment, + "extra": request.query_params.get("amount"), + }, + ) + + success_action = link.success_action(payment_hash) + if success_action: + resp = LnurlScrubActionResponse( + pr=payment_request, success_action=success_action, routes=[] + ) + else: + resp = LnurlScrubActionResponse(pr=payment_request, routes=[]) + + return resp.dict() diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py new file mode 100644 index 000000000..f16a61b18 --- /dev/null +++ b/lnbits/extensions/scrub/migrations.py @@ -0,0 +1,51 @@ +async def m001_initial(db): + """ + Initial pay table. + """ + await db.execute( + f""" + CREATE TABLE scrub.pay_links ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + webhook INTEGER NOT NULL, + payoraddress INTEGER NOT NULL + ); + """ + ) + + +async def m002_webhooks_and_success_actions(db): + """ + Webhooks and success actions. + """ + await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN webhook_url TEXT;") + await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_text TEXT;") + await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_url TEXT;") + await db.execute( + f""" + CREATE TABLE scrub.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 scrub.pay_links ADD COLUMN currency TEXT;" + ) # null = satoshis + await db.execute( + "ALTER TABLE scrub.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" + ) + await db.execute("ALTER TABLE scrub.pay_links RENAME COLUMN amount TO min;") + await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN max INTEGER;") + await db.execute("UPDATE scrub.pay_links SET max = min;") + await db.execute("DROP TABLE scrub.invoices") diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py new file mode 100644 index 000000000..6a4ee9d70 --- /dev/null +++ b/lnbits/extensions/scrub/models.py @@ -0,0 +1,64 @@ +import json +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult +from starlette.requests import Request +from fastapi.param_functions import Query +from typing import Optional, Dict +from lnbits.lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlScrubMetadata # type: ignore +from sqlite3 import Row +from pydantic import BaseModel + + +class CreateScrubLinkData(BaseModel): + description: str + min: int = Query(0.01, ge=0.01) + max: int = Query(0.01, ge=0.01) + currency: str = Query(None) + comment_chars: int = Query(0, ge=0, lt=800) + webhook_url: str = Query(None) + success_text: str = Query(None) + success_url: str = Query(None) + + +class ScrubLink(BaseModel): + id: int + wallet: str + description: str + min: int + served_meta: int + served_pr: int + webhook_url: Optional[str] + success_text: Optional[str] + success_url: Optional[str] + currency: Optional[str] + comment_chars: int + max: int + + @classmethod + def from_row(cls, row: Row) -> "ScrubLink": + data = dict(row) + return cls(**data) + + def lnurl(self, req: Request) -> str: + url = req.url_for("scrub.api_lnurl_response", link_id=self.id) + return lnurl_encode(url) + + @property + def scrubay_metadata(self) -> LnurlScrubMetadata: + return LnurlScrubMetadata(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: Dict = parse_qs(url.query) + 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/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js new file mode 100644 index 000000000..bfc8c1a6c --- /dev/null +++ b/lnbits/extensions/scrub/static/js/index.js @@ -0,0 +1,227 @@ +/* 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 mapScrubLink = 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, obj.id].join('') + return obj +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + currencies: [], + fiatRates: {}, + checker: null, + payLinks: [], + payLinksTable: { + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + fixedAmount: true, + data: {} + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + methods: { + getScrubLinks() { + LNbits.api + .request( + 'GET', + '/scrub/api/v1/links?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(response => { + this.payLinks = response.data.map(mapScrubLink) + }) + .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.updateScrubLink(wallet, data) + } else { + this.createScrubLink(wallet, data) + } + }, + resetFormData() { + this.formDialog = { + show: false, + fixedAmount: true, + data: {} + } + }, + updateScrubLink(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', + '/scrub/api/v1/links/' + data.id, + wallet.adminkey, + values + ) + .then(response => { + this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) + this.payLinks.push(mapScrubLink(response.data)) + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + createScrubLink(wallet, data) { + LNbits.api + .request('POST', '/scrub/api/v1/links', wallet.adminkey, data) + .then(response => { + this.getScrubLinks() + this.formDialog.show = false + this.resetFormData() + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deleteScrubLink(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', + '/scrub/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', '/scrub/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) + }) + } + }, + created() { + if (this.g.user.wallets.length) { + var getScrubLinks = this.getScrubLinks + getScrubLinks() + this.checker = setInterval(() => { + getScrubLinks() + }, 20000) + } + LNbits.api + .request('GET', '/scrub/api/v1/currencies') + .then(response => { + this.currencies = ['satoshis', ...response.data] + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + } +}) diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py new file mode 100644 index 000000000..af281e37b --- /dev/null +++ b/lnbits/extensions/scrub/tasks.py @@ -0,0 +1,59 @@ +import asyncio +import json +import httpx + +from lnbits.core import db as core_db +from lnbits.core.models import Scrubment +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) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Scrubment) -> None: + if "scrub" != payment.extra.get("tag"): + # not an scrub invoice + 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 = 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"), + "scrub": pay_link.id, + }, + timeout=40, + ) + await mark_webhook_sent(payment, r.status_code) + except (httpx.ConnectError, httpx.RequestError): + await mark_webhook_sent(payment, -1) + + +async def mark_webhook_sent(payment: Scrubment, status: int) -> None: + payment.extra["wh_status"] = status + + await core_db.execute( + """ + UPDATE apipayments SET extra = ? + WHERE hash = ? + """, + (json.dumps(payment.extra), payment.payment_hash), + ) diff --git a/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html b/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html new file mode 100644 index 000000000..894e45c4f --- /dev/null +++ b/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html @@ -0,0 +1,135 @@ + + + + + GET /scrub/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 }}api/v1/links -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /scrub/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 }}api/v1/links/<pay_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + POST /scrub/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 }}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 + /scrub/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 }}api/v1/links/<pay_id> -d + '{"description": <string>, "amount": <integer>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /scrub/api/v1/links/<pay_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url }}api/v1/links/<pay_id> -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/scrub/templates/lnurlp/_lnurl.html b/lnbits/extensions/scrub/templates/lnurlp/_lnurl.html new file mode 100644 index 000000000..da46d9c47 --- /dev/null +++ b/lnbits/extensions/scrub/templates/lnurlp/_lnurl.html @@ -0,0 +1,28 @@ + + + +

+ 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/lnbits/extensions/scrub/templates/lnurlp/display.html b/lnbits/extensions/scrub/templates/lnurlp/display.html new file mode 100644 index 000000000..424d3c588 --- /dev/null +++ b/lnbits/extensions/scrub/templates/lnurlp/display.html @@ -0,0 +1,47 @@ +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy LNURL +
+
+
+
+
+ + +
LNbits LNURL-pay link
+

Use an LNURL compatible bitcoin wallet to pay.

+
+ + + {% include "scrub/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/scrub/templates/lnurlp/index.html b/lnbits/extensions/scrub/templates/lnurlp/index.html new file mode 100644 index 000000000..269cff7d4 --- /dev/null +++ b/lnbits/extensions/scrub/templates/lnurlp/index.html @@ -0,0 +1,312 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New pay link + + + + + +
+
+
Scrub links
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURL-pay extension +
+
+ + + + {% include "scrub/_api_docs.html" %} + + {% include "scrub/_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 + Shareable link + + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/scrub/templates/lnurlp/print_qr.html b/lnbits/extensions/scrub/templates/lnurlp/print_qr.html new file mode 100644 index 000000000..4e3152e8d --- /dev/null +++ b/lnbits/extensions/scrub/templates/lnurlp/print_qr.html @@ -0,0 +1,27 @@ +{% extends "print.html" %} {% block page %} +
+
+ +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/scrub/views.py b/lnbits/extensions/scrub/views.py new file mode 100644 index 000000000..2726d85be --- /dev/null +++ b/lnbits/extensions/scrub/views.py @@ -0,0 +1,44 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.params import Depends +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 scrub_ext, scrub_renderer +from .crud import get_pay_link + +templates = Jinja2Templates(directory="templates") + + +@scrub_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return scrub_renderer().TemplateResponse( + "scrub/index.html", {"request": request, "user": user.dict()} + ) + + +@scrub_ext.get("/{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="Scrub link does not exist." + ) + ctx = {"request": request, "lnurl": link.lnurl(req=request)} + return scrub_renderer().TemplateResponse("scrub/display.html", ctx) + + +@scrub_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="Scrub link does not exist." + ) + ctx = {"request": request, "lnurl": link.lnurl(req=request)} + return scrub_renderer().TemplateResponse("scrub/print_qr.html", ctx) diff --git a/lnbits/extensions/scrub/views_api.py b/lnbits/extensions/scrub/views_api.py new file mode 100644 index 000000000..a264a42e5 --- /dev/null +++ b/lnbits/extensions/scrub/views_api.py @@ -0,0 +1,146 @@ +from http import HTTPStatus + +from fastapi import Request +from fastapi.param_functions import Query +from fastapi.params import Depends +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore +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 scrub_ext +from .crud import ( + create_pay_link, + delete_pay_link, + get_pay_link, + get_pay_links, + update_pay_link, +) +from .models import CreateScrubLinkData + + +@scrub_ext.get("/api/v1/currencies") +async def api_list_currencies_available(): + return list(currencies.keys()) + + +@scrub_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: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + + 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.", + ) + + +@scrub_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="Scrub 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)}} + + +@scrub_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) +@scrub_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_create_or_update( + data: CreateScrubLinkData, + link_id=None, + wallet: WalletTypeInfo = Depends(get_key_type), +): + if data.min < 1: + raise HTTPException( + detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST + ) + + if data.min > data.max: + raise HTTPException( + detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST + ) + + if data.currency == None and ( + round(data.min) != data.min or round(data.max) != data.max + ): + raise HTTPException( + detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST + ) + + if "success_url" in data and data.success_url[:8] != "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="Scrub 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) + return {**link.dict(), "lnurl": link.lnurl} + + +@scrub_ext.delete("/api/v1/links/{link_id}") +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="Scrub 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) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@scrub_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} From 5e28183a24672d9a3b98546e8b4a011be3c6f75f Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 19 May 2022 11:39:59 +0100 Subject: [PATCH 006/129] bits bobs --- lnbits/extensions/scrub/crud.py | 85 +++++------- lnbits/extensions/scrub/lnurl.py | 109 --------------- lnbits/extensions/scrub/migrations.py | 47 +------ lnbits/extensions/scrub/models.py | 40 +----- lnbits/extensions/scrub/tasks.py | 18 +-- .../scrub/templates/lnurlp/index.html | 125 +----------------- lnbits/extensions/scrub/views_api.py | 18 +-- 7 files changed, 51 insertions(+), 391 deletions(-) delete mode 100644 lnbits/extensions/scrub/lnurl.py diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py index df97f9904..0dee689cd 100644 --- a/lnbits/extensions/scrub/crud.py +++ b/lnbits/extensions/scrub/crud.py @@ -5,87 +5,62 @@ from . import db from .models import ScrubLink, CreateScrubLinkData -async def create_pay_link(data: CreateScrubLinkData, wallet_id: str) -> ScrubLink: - - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - result = await (method)( - f""" - INSERT INTO scrub.pay_links ( +async def create_scrub_link(wallet_id: str, data: CreateSatsDiceLink) -> satsdiceLink: + satsdice_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO scrub.scrub_links ( + id, wallet, description, - min, - max, - served_meta, - served_pr, - webhook_url, - success_text, - success_url, - comment_chars, - currency + payoraddress, ) - VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) - {returning} + VALUES (?, ?, ?) """, ( - wallet_id, - data.description, - data.min, - data.max, - data.webhook_url, - data.success_text, - data.success_url, - data.comment_chars, - data.currency, + satsdice_id, + wallet, + description, + payoraddress, ), ) - if db.type == SQLITE: - link_id = result._result_proxy.lastrowid - else: - link_id = result[0] - - link = await get_pay_link(link_id) + link = await get_satsdice_pay(satsdice_id) assert link, "Newly created link couldn't be retrieved" return link -async def get_pay_link(link_id: int) -> Optional[ScrubLink]: - row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) - return ScrubLink.from_row(row) if row else None +async def get_scrub_link(link_id: str) -> Optional[satsdiceLink]: + row = await db.fetchone( + "SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,) + ) + return satsdiceLink(**row) if row else None -async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]: +async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[satsdiceLink]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] q = ",".join(["?"] * len(wallet_ids)) rows = await db.fetchall( f""" - SELECT * FROM scrub.pay_links WHERE wallet IN ({q}) - ORDER BY Id + SELECT * FROM scrub.scrub_links WHERE wallet IN ({q}) + ORDER BY id """, (*wallet_ids,), ) - return [ScrubLink.from_row(row) for row in rows] + return [satsdiceLink(**row) for row in rows] -async def update_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]: +async def update_scrub_link(link_id: int, **kwargs) -> Optional[satsdiceLink]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( - f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + f"UPDATE scrub.scrub_links SET {q} WHERE id = ?", + (*kwargs.values(), link_id), ) - row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) - return ScrubLink.from_row(row) if row else None - - -async def increment_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]: - q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) + row = await db.fetchone( + "SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,) ) - row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) - return ScrubLink.from_row(row) if row else None + return satsdiceLink(**row) if row else None - -async def delete_pay_link(link_id: int) -> None: - await db.execute("DELETE FROM scrub.pay_links WHERE id = ?", (link_id,)) +async def delete_scrub_link(link_id: int) -> None: + await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,)) diff --git a/lnbits/extensions/scrub/lnurl.py b/lnbits/extensions/scrub/lnurl.py deleted file mode 100644 index 6d33479f4..000000000 --- a/lnbits/extensions/scrub/lnurl.py +++ /dev/null @@ -1,109 +0,0 @@ -import hashlib -import math -from http import HTTPStatus - -from fastapi import Request -from lnurl import ( # type: ignore - LnurlErrorResponse, - LnurlScrubActionResponse, - LnurlScrubResponse, -) -from starlette.exceptions import HTTPException - -from lnbits.core.services import create_invoice -from lnbits.utils.exchange_rates import get_fiat_rate_satoshis - -from . import scrub_ext -from .crud import increment_pay_link - - -@scrub_ext.get( - "/api/v1/lnurl/{link_id}", - status_code=HTTPStatus.OK, - name="scrub.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="Scrub link does not exist." - ) - - rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 - - resp = LnurlScrubResponse( - callback=request.url_for("scrub.api_lnurl_callback", link_id=link.id), - min_sendable=math.ceil(link.min * rate) * 1000, - max_sendable=round(link.max * rate) * 1000, - metadata=link.scrubay_metadata, - ) - params = resp.dict() - - if link.comment_chars > 0: - params["commentAllowed"] = link.comment_chars - - return params - - -@scrub_ext.get( - "/api/v1/lnurl/cb/{link_id}", - status_code=HTTPStatus.OK, - name="scrub.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="Scrub 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, - description_hash=hashlib.sha256( - link.scrubay_metadata.encode("utf-8") - ).digest(), - extra={ - "tag": "scrub", - "link": link.id, - "comment": comment, - "extra": request.query_params.get("amount"), - }, - ) - - success_action = link.success_action(payment_hash) - if success_action: - resp = LnurlScrubActionResponse( - pr=payment_request, success_action=success_action, routes=[] - ) - else: - resp = LnurlScrubActionResponse(pr=payment_request, routes=[]) - - return resp.dict() diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py index f16a61b18..c52a62fe5 100644 --- a/lnbits/extensions/scrub/migrations.py +++ b/lnbits/extensions/scrub/migrations.py @@ -1,51 +1,14 @@ async def m001_initial(db): """ - Initial pay table. + Initial scrub table. """ await db.execute( f""" - CREATE TABLE scrub.pay_links ( - id {db.serial_primary_key}, + CREATE TABLE scrub.scrub_links ( + id TEXT PRIMARY KEY, wallet TEXT NOT NULL, description TEXT NOT NULL, - webhook INTEGER NOT NULL, - payoraddress INTEGER NOT NULL + payoraddress TEXT NOT NULL ); """ - ) - - -async def m002_webhooks_and_success_actions(db): - """ - Webhooks and success actions. - """ - await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN webhook_url TEXT;") - await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_text TEXT;") - await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_url TEXT;") - await db.execute( - f""" - CREATE TABLE scrub.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 scrub.pay_links ADD COLUMN currency TEXT;" - ) # null = satoshis - await db.execute( - "ALTER TABLE scrub.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" - ) - await db.execute("ALTER TABLE scrub.pay_links RENAME COLUMN amount TO min;") - await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN max INTEGER;") - await db.execute("UPDATE scrub.pay_links SET max = min;") - await db.execute("DROP TABLE scrub.invoices") + ) \ No newline at end of file diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py index 6a4ee9d70..b4eae6315 100644 --- a/lnbits/extensions/scrub/models.py +++ b/lnbits/extensions/scrub/models.py @@ -8,31 +8,11 @@ from lnurl.types import LnurlScrubMetadata # type: ignore from sqlite3 import Row from pydantic import BaseModel - -class CreateScrubLinkData(BaseModel): - description: str - min: int = Query(0.01, ge=0.01) - max: int = Query(0.01, ge=0.01) - currency: str = Query(None) - comment_chars: int = Query(0, ge=0, lt=800) - webhook_url: str = Query(None) - success_text: str = Query(None) - success_url: str = Query(None) - - class ScrubLink(BaseModel): id: int wallet: str description: str - min: int - served_meta: int - served_pr: int - webhook_url: Optional[str] - success_text: Optional[str] - success_url: Optional[str] - currency: Optional[str] - comment_chars: int - max: int + payoraddress: str @classmethod def from_row(cls, row: Row) -> "ScrubLink": @@ -45,20 +25,4 @@ class ScrubLink(BaseModel): @property def scrubay_metadata(self) -> LnurlScrubMetadata: - return LnurlScrubMetadata(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: Dict = parse_qs(url.query) - 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 + return LnurlScrubMetadata(json.dumps([["text/plain", self.description]])) \ No newline at end of file diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py index af281e37b..b99dc35c7 100644 --- a/lnbits/extensions/scrub/tasks.py +++ b/lnbits/extensions/scrub/tasks.py @@ -28,23 +28,7 @@ async def on_invoice_paid(payment: Scrubment) -> None: 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 = 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"), - "scrub": pay_link.id, - }, - timeout=40, - ) - await mark_webhook_sent(payment, r.status_code) - except (httpx.ConnectError, httpx.RequestError): - await mark_webhook_sent(payment, -1) + # PAY LNURLP AND LNADDRESS async def mark_webhook_sent(payment: Scrubment, status: int) -> None: diff --git a/lnbits/extensions/scrub/templates/lnurlp/index.html b/lnbits/extensions/scrub/templates/lnurlp/index.html index 269cff7d4..ec9aafea9 100644 --- a/lnbits/extensions/scrub/templates/lnurlp/index.html +++ b/lnbits/extensions/scrub/templates/lnurlp/index.html @@ -154,76 +154,13 @@ type="text" label="Item description *" > -
- - -
-
-
- -
-
- -
-
+ - - -
Create pay link - - - - {% 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 - Shareable link - - Close -
-
-
{% endblock %} {% block scripts %} {{ window_vars(user) }} diff --git a/lnbits/extensions/scrub/views_api.py b/lnbits/extensions/scrub/views_api.py index a264a42e5..805d481b5 100644 --- a/lnbits/extensions/scrub/views_api.py +++ b/lnbits/extensions/scrub/views_api.py @@ -12,11 +12,11 @@ from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis from . import scrub_ext from .crud import ( - create_pay_link, - delete_pay_link, - get_pay_link, - get_pay_links, - update_pay_link, + create_scrub_link, + delete_scrub_link, + get_scrub_link, + get_scrub_links, + update_scrub_link, ) from .models import CreateScrubLinkData @@ -39,14 +39,14 @@ async def api_links( try: return [ - {**link.dict(), "lnurl": link.lnurl(req)} + {**link.dict()} for link in await get_pay_links(wallet_ids) ] - except LnurlInvalidUrl: + except: raise HTTPException( - status_code=HTTPStatus.UPGRADE_REQUIRED, - detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", + status_code=HTTPStatus.NOT_FOUND, + detail="No links available", ) From 395b4379a4e870ed688711809b1733031168e0d3 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 7 Jun 2022 11:04:34 +0100 Subject: [PATCH 007/129] initial --- lnbits/extensions/scrub/__init__.py | 1 - lnbits/extensions/scrub/crud.py | 16 ++++++++-------- lnbits/extensions/scrub/models.py | 14 ++++++++------ lnbits/extensions/scrub/static/js/index.js | 6 +----- lnbits/extensions/scrub/tasks.py | 8 ++++---- .../templates/{lnurlp => scrub}/_api_docs.html | 0 .../templates/{lnurlp => scrub}/_lnurl.html | 0 .../templates/{lnurlp => scrub}/display.html | 0 .../scrub/templates/{lnurlp => scrub}/index.html | 8 ++++---- .../templates/{lnurlp => scrub}/print_qr.html | 0 lnbits/extensions/scrub/views.py | 6 +++--- lnbits/extensions/scrub/views_api.py | 8 +++++--- 12 files changed, 33 insertions(+), 34 deletions(-) rename lnbits/extensions/scrub/templates/{lnurlp => scrub}/_api_docs.html (100%) rename lnbits/extensions/scrub/templates/{lnurlp => scrub}/_lnurl.html (100%) rename lnbits/extensions/scrub/templates/{lnurlp => scrub}/display.html (100%) rename lnbits/extensions/scrub/templates/{lnurlp => scrub}/index.html (97%) rename lnbits/extensions/scrub/templates/{lnurlp => scrub}/print_qr.html (100%) diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py index 3d25a097e..777a7c3f9 100644 --- a/lnbits/extensions/scrub/__init__.py +++ b/lnbits/extensions/scrub/__init__.py @@ -24,7 +24,6 @@ def scrub_renderer(): return template_renderer(["lnbits/extensions/scrub/templates"]) -from .lnurl import * # noqa from .tasks import wait_for_paid_invoices from .views import * # noqa from .views_api import * # noqa diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py index 0dee689cd..b6d1bf728 100644 --- a/lnbits/extensions/scrub/crud.py +++ b/lnbits/extensions/scrub/crud.py @@ -2,10 +2,10 @@ from typing import List, Optional, Union from lnbits.db import SQLITE from . import db -from .models import ScrubLink, CreateScrubLinkData +from .models import ScrubLink -async def create_scrub_link(wallet_id: str, data: CreateSatsDiceLink) -> satsdiceLink: +async def create_scrub_link(wallet_id: str, data: ScrubLink) -> ScrubLink: satsdice_id = urlsafe_short_hash() await db.execute( """ @@ -29,14 +29,14 @@ async def create_scrub_link(wallet_id: str, data: CreateSatsDiceLink) -> satsdic return link -async def get_scrub_link(link_id: str) -> Optional[satsdiceLink]: +async def get_scrub_link(link_id: str) -> Optional[ScrubLink]: row = await db.fetchone( "SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,) ) - return satsdiceLink(**row) if row else None + return ScrubLink(**row) if row else None -async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[satsdiceLink]: +async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]: if isinstance(wallet_ids, str): wallet_ids = [wallet_ids] @@ -48,10 +48,10 @@ async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[satsdiceLin """, (*wallet_ids,), ) - return [satsdiceLink(**row) for row in rows] + return [ScrubLink(**row) for row in rows] -async def update_scrub_link(link_id: int, **kwargs) -> Optional[satsdiceLink]: +async def update_scrub_link(link_id: int, **kwargs) -> Optional[ScrubLink]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( f"UPDATE scrub.scrub_links SET {q} WHERE id = ?", @@ -60,7 +60,7 @@ async def update_scrub_link(link_id: int, **kwargs) -> Optional[satsdiceLink]: row = await db.fetchone( "SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,) ) - return satsdiceLink(**row) if row else None + return ScrubLink(**row) if row else None async def delete_scrub_link(link_id: int) -> None: await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,)) diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py index b4eae6315..41fa5f37d 100644 --- a/lnbits/extensions/scrub/models.py +++ b/lnbits/extensions/scrub/models.py @@ -4,10 +4,16 @@ from starlette.requests import Request from fastapi.param_functions import Query from typing import Optional, Dict from lnbits.lnurl import encode as lnurl_encode # type: ignore -from lnurl.types import LnurlScrubMetadata # type: ignore from sqlite3 import Row from pydantic import BaseModel + +class CreateScrubLink(BaseModel): + wallet: str + description: str + payoraddress: str + + class ScrubLink(BaseModel): id: int wallet: str @@ -21,8 +27,4 @@ class ScrubLink(BaseModel): def lnurl(self, req: Request) -> str: url = req.url_for("scrub.api_lnurl_response", link_id=self.id) - return lnurl_encode(url) - - @property - def scrubay_metadata(self) -> LnurlScrubMetadata: - return LnurlScrubMetadata(json.dumps([["text/plain", self.description]])) \ No newline at end of file + return lnurl_encode(url) \ No newline at end of file diff --git a/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js index bfc8c1a6c..ab41381c8 100644 --- a/lnbits/extensions/scrub/static/js/index.js +++ b/lnbits/extensions/scrub/static/js/index.js @@ -106,11 +106,7 @@ new Vue({ 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 + console.log(wallet) if (data.id) { this.updateScrubLink(wallet, data) diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py index b99dc35c7..9627707c8 100644 --- a/lnbits/extensions/scrub/tasks.py +++ b/lnbits/extensions/scrub/tasks.py @@ -3,10 +3,10 @@ import json import httpx from lnbits.core import db as core_db -from lnbits.core.models import Scrubment +from .models import ScrubLink from lnbits.tasks import register_invoice_listener -from .crud import get_pay_link +from .crud import get_scrub_link async def wait_for_paid_invoices(): @@ -18,7 +18,7 @@ async def wait_for_paid_invoices(): await on_invoice_paid(payment) -async def on_invoice_paid(payment: Scrubment) -> None: +async def on_invoice_paid(payment: ScrubLink) -> None: if "scrub" != payment.extra.get("tag"): # not an scrub invoice return @@ -31,7 +31,7 @@ async def on_invoice_paid(payment: Scrubment) -> None: # PAY LNURLP AND LNADDRESS -async def mark_webhook_sent(payment: Scrubment, status: int) -> None: +async def mark_webhook_sent(payment: ScrubLink, status: int) -> None: payment.extra["wh_status"] = status await core_db.execute( diff --git a/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html b/lnbits/extensions/scrub/templates/scrub/_api_docs.html similarity index 100% rename from lnbits/extensions/scrub/templates/lnurlp/_api_docs.html rename to lnbits/extensions/scrub/templates/scrub/_api_docs.html diff --git a/lnbits/extensions/scrub/templates/lnurlp/_lnurl.html b/lnbits/extensions/scrub/templates/scrub/_lnurl.html similarity index 100% rename from lnbits/extensions/scrub/templates/lnurlp/_lnurl.html rename to lnbits/extensions/scrub/templates/scrub/_lnurl.html diff --git a/lnbits/extensions/scrub/templates/lnurlp/display.html b/lnbits/extensions/scrub/templates/scrub/display.html similarity index 100% rename from lnbits/extensions/scrub/templates/lnurlp/display.html rename to lnbits/extensions/scrub/templates/scrub/display.html diff --git a/lnbits/extensions/scrub/templates/lnurlp/index.html b/lnbits/extensions/scrub/templates/scrub/index.html similarity index 97% rename from lnbits/extensions/scrub/templates/lnurlp/index.html rename to lnbits/extensions/scrub/templates/scrub/index.html index ec9aafea9..95354da42 100644 --- a/lnbits/extensions/scrub/templates/lnurlp/index.html +++ b/lnbits/extensions/scrub/templates/scrub/index.html @@ -5,7 +5,7 @@ New pay linkNew scrub link @@ -28,9 +28,9 @@