diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md new file mode 100644 index 00000000..680c5e6d --- /dev/null +++ b/lnbits/extensions/scrub/README.md @@ -0,0 +1,28 @@ +# Scrub + +## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address + +SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress! + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +1. Create an scrub (New Scrub link)\ + ![create scrub](https://i.imgur.com/LUeNkzM.jpg) + + - select the wallet to be _scrubbed_ + - make a small description + - enter either an LNURL pay or a lightning address + + Make sure your LNURL or LNaddress is correct! + +2. A new scrub will show on the _Scrub links_ section\ + ![scrub](https://i.imgur.com/LNoFkeu.jpg) + + - only one scrub can be created for each wallet! + - You can _edit_ or _delete_ the Scrub at any time\ + ![edit scrub](https://i.imgur.com/Qu65lGG.jpg) + +3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\ + ![wallet view](https://i.imgur.com/S6EWWCP.jpg) diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py new file mode 100644 index 00000000..777a7c3f --- /dev/null +++ b/lnbits/extensions/scrub/__init__.py @@ -0,0 +1,34 @@ +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 .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 00000000..df9e0038 --- /dev/null +++ b/lnbits/extensions/scrub/config.json @@ -0,0 +1,6 @@ +{ + "name": "Scrub", + "short_description": "Pass payments to LNURLp/LNaddress", + "icon": "send", + "contributors": ["arcbtc", "talvasconcelos"] +} diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py new file mode 100644 index 00000000..1772a8c5 --- /dev/null +++ b/lnbits/extensions/scrub/crud.py @@ -0,0 +1,80 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateScrubLink, ScrubLink + + +async def create_scrub_link(data: CreateScrubLink) -> ScrubLink: + scrub_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO scrub.scrub_links ( + id, + wallet, + description, + payoraddress + ) + VALUES (?, ?, ?, ?) + """, + ( + scrub_id, + data.wallet, + data.description, + data.payoraddress, + ), + ) + link = await get_scrub_link(scrub_id) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_scrub_link(link_id: str) -> Optional[ScrubLink]: + row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,)) + return ScrubLink(**row) if row else None + + +async def get_scrub_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.scrub_links WHERE wallet IN ({q}) + ORDER BY id + """, + (*wallet_ids,), + ) + return [ScrubLink(**row) for row in rows] + + +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 = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,)) + 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,)) + + +async def get_scrub_by_wallet(wallet_id) -> Optional[ScrubLink]: + row = await db.fetchone( + "SELECT * from scrub.scrub_links WHERE wallet = ?", + (wallet_id,), + ) + return ScrubLink(**row) if row else None + + +async def unique_scrubed_wallet(wallet_id): + (row,) = await db.fetchone( + "SELECT COUNT(wallet) FROM scrub.scrub_links WHERE wallet = ?", + (wallet_id,), + ) + return row diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py new file mode 100644 index 00000000..f8f2ba43 --- /dev/null +++ b/lnbits/extensions/scrub/migrations.py @@ -0,0 +1,14 @@ +async def m001_initial(db): + """ + Initial scrub table. + """ + await db.execute( + f""" + CREATE TABLE scrub.scrub_links ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + payoraddress TEXT NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py new file mode 100644 index 00000000..db05e4f1 --- /dev/null +++ b/lnbits/extensions/scrub/models.py @@ -0,0 +1,28 @@ +from sqlite3 import Row + +from pydantic import BaseModel +from starlette.requests import Request + +from lnbits.lnurl import encode as lnurl_encode # type: ignore + + +class CreateScrubLink(BaseModel): + wallet: str + description: str + payoraddress: str + + +class ScrubLink(BaseModel): + id: str + wallet: str + description: str + payoraddress: str + + @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) diff --git a/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js new file mode 100644 index 00000000..43990792 --- /dev/null +++ b/lnbits/extensions/scrub/static/js/index.js @@ -0,0 +1,143 @@ +/* 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 { + checker: null, + payLinks: [], + payLinksTable: { + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + 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() + }, + openUpdateDialog(linkId) { + const link = _.findWhere(this.payLinks, {id: linkId}) + + this.formDialog.data = _.clone(link._data) + this.formDialog.show = true + }, + sendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + let data = Object.freeze(this.formDialog.data) + console.log(wallet, data) + + if (data.id) { + this.updateScrubLink(wallet, data) + } else { + this.createScrubLink(wallet, data) + } + }, + resetFormData() { + this.formDialog = { + show: false, + data: {} + } + }, + updateScrubLink(wallet, data) { + LNbits.api + .request('PUT', '/scrub/api/v1/links/' + data.id, wallet.adminkey, data) + .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 => { + console.log('RES', 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) + }) + }) + } + }, + created() { + if (this.g.user.wallets.length) { + var getScrubLinks = this.getScrubLinks + getScrubLinks() + } + } +}) diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py new file mode 100644 index 00000000..87e1364b --- /dev/null +++ b/lnbits/extensions/scrub/tasks.py @@ -0,0 +1,85 @@ +import asyncio +import json +from http import HTTPStatus +from urllib.parse import urlparse + +import httpx +from fastapi import HTTPException + +from lnbits import bolt11 +from lnbits.core.models import Payment +from lnbits.core.services import pay_invoice +from lnbits.tasks import register_invoice_listener + +from .crud import get_scrub_by_wallet + + +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: Payment) -> None: + # (avoid loops) + if "scrubed" == payment.extra.get("tag"): + # already scrubbed + return + + scrub_link = await get_scrub_by_wallet(payment.wallet_id) + + if not scrub_link: + return + + from lnbits.core.views.api import api_lnurlscan + + # DECODE LNURLP OR LNADDRESS + data = await api_lnurlscan(scrub_link.payoraddress) + + # I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267 + domain = urlparse(data["callback"]).netloc + + async with httpx.AsyncClient() as client: + try: + r = await client.get( + data["callback"], + params={"amount": payment.amount}, + timeout=40, + ) + if r.is_error: + raise httpx.ConnectError + except (httpx.ConnectError, httpx.RequestError): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to connect to {domain}.", + ) + + params = json.loads(r.text) + if params.get("status") == "ERROR": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} said: '{params.get('reason', '')}'", + ) + + invoice = bolt11.decode(params["pr"]) + if invoice.amount_msat != payment.amount: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.", + ) + + payment_hash = await pay_invoice( + wallet_id=payment.wallet_id, + payment_request=params["pr"], + description=data["description"], + extra={"tag": "scrubed"}, + ) + + return { + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } diff --git a/lnbits/extensions/scrub/templates/scrub/_api_docs.html b/lnbits/extensions/scrub/templates/scrub/_api_docs.html new file mode 100644 index 00000000..ae3f44d8 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/_api_docs.html @@ -0,0 +1,136 @@ + + + + + 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 }}scrub/api/v1/links?all_wallets=true + -H "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /scrub/api/v1/links/<scrub_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X GET {{ request.base_url }}scrub/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)
+ {"wallet": <string>, "description": <string>, + "payoraddress": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}scrub/api/v1/links -d '{"wallet": + <string>, "description": <string>, "payoraddress": + <string>}' -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)
+ {"wallet": <string>, "description": <string>, + "payoraddress": <string>} +
+ Returns 200 OK (application/json) +
+ {"id": <string>, "wallet": <string>, "description": + <string>, "payoraddress": <string>} +
Curl example
+ curl -X PUT {{ request.base_url }}scrub/api/v1/links/<pay_id> + -d '{"wallet": <string>, "description": <string>, + "payoraddress": <string>}' -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 + }}scrub/api/v1/links/<pay_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/scrub/templates/scrub/_lnurl.html b/lnbits/extensions/scrub/templates/scrub/_lnurl.html new file mode 100644 index 00000000..da46d9c4 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/_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/scrub/index.html b/lnbits/extensions/scrub/templates/scrub/index.html new file mode 100644 index 00000000..c063c858 --- /dev/null +++ b/lnbits/extensions/scrub/templates/scrub/index.html @@ -0,0 +1,140 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New scrub link + + + + + +
+
+
Scrub links
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
{{SITE_TITLE}} Scrub extension
+
+ + + + {% include "scrub/_api_docs.html" %} + + {% include "scrub/_lnurl.html" %} + + +
+
+ + + + + + + + + +
+ Update pay link + Create pay link + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/scrub/views.py b/lnbits/extensions/scrub/views.py new file mode 100644 index 00000000..73c7ffd9 --- /dev/null +++ b/lnbits/extensions/scrub/views.py @@ -0,0 +1,18 @@ +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import scrub_ext, scrub_renderer + +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()} + ) diff --git a/lnbits/extensions/scrub/views_api.py b/lnbits/extensions/scrub/views_api.py new file mode 100644 index 00000000..3714a304 --- /dev/null +++ b/lnbits/extensions/scrub/views_api.py @@ -0,0 +1,112 @@ +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, require_admin_key + +from . import scrub_ext +from .crud import ( + create_scrub_link, + delete_scrub_link, + get_scrub_link, + get_scrub_links, + unique_scrubed_wallet, + update_scrub_link, +) +from .models import CreateScrubLink + + +@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() for link in await get_scrub_links(wallet_ids)] + + except: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No SCRUB links made yet", + ) + + +@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_scrub_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 + + +@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_scrub_create_or_update( + data: CreateScrubLink, + link_id=None, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + if link_id: + link = await get_scrub_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_scrub_link(**data.dict(), link_id=link_id) + else: + wallet_has_scrub = await unique_scrubed_wallet(wallet_id=data.wallet) + if wallet_has_scrub > 0: + raise HTTPException( + detail="Wallet is already being Scrubbed", + status_code=HTTPStatus.FORBIDDEN, + ) + link = await create_scrub_link(data=data) + + return link + + +@scrub_ext.delete("/api/v1/links/{link_id}") +async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): + link = await get_scrub_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_scrub_link(link_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip index 89cafa3f..6f7165b3 100644 Binary files a/tests/data/mock_data.zip and b/tests/data/mock_data.zip differ diff --git a/tools/conv.py b/tools/conv.py index 0fc548ab..b93bcfbe 100644 --- a/tools/conv.py +++ b/tools/conv.py @@ -704,6 +704,19 @@ def migrate_ext(sqlite_db_file, schema, ignore_missing=True): VALUES (%s, %s, %s, %s, %s, %s); """ insert_to_pg(q, res.fetchall()) + elif schema == "scrub": + # SCRUB LINKS + res = sq.execute("SELECT * FROM scrub_links;") + q = f""" + INSERT INTO scrub.scrub_links ( + id, + wallet, + description, + payoraddress + ) + VALUES (%s, %s, %s, %s); + """ + insert_to_pg(q, res.fetchall()) else: print(f"❌ Not implemented: {schema}") sq.close()