commit fcb973b540f311b583c48dc79e7303af19272a5c Author: dni ⚡ Date: Fri Feb 17 11:57:27 2023 +0100 init commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..92df069 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,19 @@ +name: release github version +on: + push: + tags: + - "[0-9]+.[0-9]+" +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fce2c6e --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# LNURLw + +## Create a static QR code people can use to withdraw funds from a Lightning Network wallet + +LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet. + +The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone. + +LNURL withdraw is a **very powerful tool** and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. **This functionality has not existed in money before**. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +## Usage + +#### Quick Vouchers + +LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc... + +1. Create Quick Vouchers\ + ![quick vouchers](https://i.imgur.com/IUfwdQz.jpg) + - select wallet + - set the amount each voucher will allow someone to withdraw + - set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_ +2. You can now print, share, display your LNURLw links or QR codes\ + ![lnurlw created](https://i.imgur.com/X00twiX.jpg) + - on details you can print the vouchers\ + ![printable vouchers](https://i.imgur.com/2xLHbob.jpg) + - every printed LNURLw QR code is unique, it can only be used once +3. Bonus: you can use an LNbits themed voucher, or use a custom one. There's a _template.svg_ file in `static/images` folder if you want to create your own.\ + ![voucher](https://i.imgur.com/qyQoHi3.jpg) + +#### Advanced + +1. Create the Advanced LNURLw\ + ![create advanced lnurlw](https://i.imgur.com/OR0f885.jpg) + - set the wallet + - set a title for the LNURLw (it will show up in users wallet) + - define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value + - set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times + - LNbits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans + - you can set the time in _seconds, minutes or hours_ + - the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned +2. Print, share or display your LNURLw link or it's QR code\ + ![lnurlw created](https://i.imgur.com/X00twiX.jpg) + +**LNbits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNbits wallet! + +![](https://i.imgur.com/2zZ7mi8.jpg) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cb5eb9c --- /dev/null +++ b/__init__.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_withdraw") + +withdraw_static_files = [ + { + "path": "/withdraw/static", + "app": StaticFiles(packages=[("lnbits", "extensions/withdraw/static")]), + "name": "withdraw_static", + } +] + + +withdraw_ext: APIRouter = APIRouter(prefix="/withdraw", tags=["withdraw"]) + + +def withdraw_renderer(): + return template_renderer(["lnbits/extensions/withdraw/templates"]) + + +from .lnurl import * # noqa: F401,F403 +from .views import * # noqa: F401,F403 +from .views_api import * # noqa: F401,F403 diff --git a/config.json b/config.json new file mode 100644 index 0000000..c22d69c --- /dev/null +++ b/config.json @@ -0,0 +1,6 @@ +{ + "name": "LNURLw", + "short_description": "Make LNURL withdraw links", + "tile": "/withdraw/static/image/lnurl-withdraw.png", + "contributors": ["arcbtc", "eillarra"] +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..83dd059 --- /dev/null +++ b/crud.py @@ -0,0 +1,173 @@ +from datetime import datetime +from typing import List, Optional, Union + +import shortuuid + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateWithdrawData, HashCheck, WithdrawLink + + +async def create_withdraw_link( + data: CreateWithdrawData, wallet_id: str +) -> WithdrawLink: + link_id = urlsafe_short_hash()[:6] + available_links = ",".join([str(i) for i in range(data.uses)]) + await db.execute( + """ + INSERT INTO withdraw.withdraw_link ( + id, + wallet, + title, + min_withdrawable, + max_withdrawable, + uses, + wait_time, + is_unique, + unique_hash, + k1, + open_time, + usescsv, + webhook_url, + webhook_headers, + webhook_body, + custom_url + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + link_id, + wallet_id, + data.title, + data.min_withdrawable, + data.max_withdrawable, + data.uses, + data.wait_time, + int(data.is_unique), + urlsafe_short_hash(), + urlsafe_short_hash(), + int(datetime.now().timestamp()) + data.wait_time, + available_links, + data.webhook_url, + data.webhook_headers, + data.webhook_body, + data.custom_url, + ), + ) + link = await get_withdraw_link(link_id, 0) + assert link, "Newly created link couldn't be retrieved" + return link + + +async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: + row = await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) + ) + if not row: + return None + + link = dict(**row) + link["number"] = num + + return WithdrawLink.parse_obj(link) + + +async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]: + row = await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,) + ) + if not row: + return None + + link = dict(**row) + link["number"] = num + + return WithdrawLink.parse_obj(link) + + +async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[WithdrawLink]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [WithdrawLink(**row) for row in rows] + + +async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: + unique_links = [ + x.strip() + for x in link.usescsv.split(",") + if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) + ] + await update_withdraw_link( + link.id, + usescsv=",".join(unique_links), + ) + + +async def increment_withdraw_link(link: WithdrawLink) -> None: + await update_withdraw_link( + link.id, + used=link.used + 1, + open_time=link.wait_time + int(datetime.now().timestamp()), + ) + + +async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]: + if "is_unique" in kwargs: + kwargs["is_unique"] = int(kwargs["is_unique"]) + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE withdraw.withdraw_link SET {q} WHERE id = ?", + (*kwargs.values(), link_id), + ) + row = await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) + ) + return WithdrawLink(**row) if row else None + + +async def delete_withdraw_link(link_id: str) -> None: + await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) + + +def chunks(lst, n): + for i in range(0, len(lst), n): + yield lst[i : i + n] + + +async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: + await db.execute( + """ + INSERT INTO withdraw.hash_check ( + id, + lnurl_id + ) + VALUES (?, ?) + """, + (the_hash, lnurl_id), + ) + hashCheck = await get_hash_check(the_hash, lnurl_id) + return hashCheck + + +async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: + rowid = await db.fetchone( + "SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,) + ) + rowlnurl = await db.fetchone( + "SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,) + ) + if not rowlnurl: + await create_hash_check(the_hash, lnurl_id) + return HashCheck(lnurl=True, hash=False) + else: + if not rowid: + await create_hash_check(the_hash, lnurl_id) + return HashCheck(lnurl=True, hash=False) + else: + return HashCheck(lnurl=True, hash=True) diff --git a/lnurl.py b/lnurl.py new file mode 100644 index 0000000..5ef521f --- /dev/null +++ b/lnurl.py @@ -0,0 +1,200 @@ +import json +from datetime import datetime +from http import HTTPStatus + +import httpx +import shortuuid +from fastapi import HTTPException, Query, Request, Response +from loguru import logger + +from lnbits.core.crud import update_payment_extra +from lnbits.core.services import pay_invoice + +from . import withdraw_ext +from .crud import ( + get_withdraw_link_by_hash, + increment_withdraw_link, + remove_unique_withdraw_link, +) +from .models import WithdrawLink + + +@withdraw_ext.get( + "/api/v1/lnurl/{unique_hash}", + response_class=Response, + name="withdraw.api_lnurl_response", +) +async def api_lnurl_response(request: Request, unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + + if link.is_spent: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) + url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) + withdrawResponse = { + "tag": "withdrawRequest", + "callback": url, + "k1": link.k1, + "minWithdrawable": link.min_withdrawable * 1000, + "maxWithdrawable": link.max_withdrawable * 1000, + "defaultDescription": link.title, + "webhook_url": link.webhook_url, + "webhook_headers": link.webhook_headers, + "webhook_body": link.webhook_body, + } + + return json.dumps(withdrawResponse) + + +@withdraw_ext.get( + "/api/v1/lnurl/cb/{unique_hash}", + name="withdraw.api_lnurl_callback", + summary="lnurl withdraw callback", + description=""" + This endpoints allows you to put unique_hash, k1 + and a payment_request to get your payment_request paid. + """, + response_description="JSON with status", + responses={ + 200: {"description": "status: OK"}, + 400: {"description": "k1 is wrong or link open time or withdraw not working."}, + 404: {"description": "withdraw link not found."}, + 405: {"description": "withdraw link is spent."}, + }, +) +async def api_lnurl_callback( + unique_hash, + k1: str = Query(...), + pr: str = Query(...), + id_unique_hash=None, +): + link = await get_withdraw_link_by_hash(unique_hash) + now = int(datetime.now().timestamp()) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found." + ) + + if link.is_spent: + raise HTTPException( + status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="withdraw is spent." + ) + + if link.k1 != k1: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="k1 is wrong.") + + if now < link.open_time: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"wait link open_time {link.open_time - now} seconds.", + ) + + if id_unique_hash: + if check_unique_link(link, id_unique_hash): + await remove_unique_withdraw_link(link, id_unique_hash) + else: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found." + ) + + try: + payment_hash = await pay_invoice( + wallet_id=link.wallet, + payment_request=pr, + max_sat=link.max_withdrawable, + extra={"tag": "withdraw"}, + ) + await increment_withdraw_link(link) + if link.webhook_url: + await dispatch_webhook(link, payment_hash, pr) + return {"status": "OK"} + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(e)}" + ) + + +def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool: + return any( + unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) + for x in link.usescsv.split(",") + ) + + +async def dispatch_webhook( + link: WithdrawLink, payment_hash: str, payment_request: str +) -> None: + async with httpx.AsyncClient() as client: + try: + r: httpx.Response = await client.post( + link.webhook_url, + json={ + "payment_hash": payment_hash, + "payment_request": payment_request, + "lnurlw": link.id, + "body": json.loads(link.webhook_body) if link.webhook_body else "", + }, + headers=json.loads(link.webhook_headers) + if link.webhook_headers + else None, + timeout=40, + ) + await update_payment_extra( + payment_hash=payment_hash, + extra={ + "wh_success": r.is_success, + "wh_message": r.reason_phrase, + "wh_response": r.text, + }, + outgoing=True, + ) + except Exception as exc: + # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid + logger.error("Caught exception when dispatching webhook url: " + str(exc)) + await update_payment_extra( + payment_hash=payment_hash, + extra={"wh_success": False, "wh_message": str(exc)}, + outgoing=True, + ) + + +# FOR LNURLs WHICH ARE UNIQUE +@withdraw_ext.get( + "/api/v1/lnurl/{unique_hash}/{id_unique_hash}", + response_class=Response, + name="withdraw.api_lnurl_multi_response", +) +async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): + link = await get_withdraw_link_by_hash(unique_hash) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." + ) + + if link.is_spent: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) + + if not check_unique_link(link, id_unique_hash): + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." + ) + + url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) + withdrawResponse = { + "tag": "withdrawRequest", + "callback": url + "?id_unique_hash=" + id_unique_hash, + "k1": link.k1, + "minWithdrawable": link.min_withdrawable * 1000, + "maxWithdrawable": link.max_withdrawable * 1000, + "defaultDescription": link.title, + } + return json.dumps(withdrawResponse) diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..8153bea --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "withdraw", + "organisation": "lnbits", + "repository": "withdraw" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..95805ae --- /dev/null +++ b/migrations.py @@ -0,0 +1,134 @@ +async def m001_initial(db): + """ + Creates an improved withdraw table and migrates the existing data. + """ + await db.execute( + f""" + CREATE TABLE withdraw.withdraw_links ( + id TEXT PRIMARY KEY, + wallet TEXT, + title TEXT, + min_withdrawable {db.big_int} DEFAULT 1, + max_withdrawable {db.big_int} DEFAULT 1, + uses INTEGER DEFAULT 1, + wait_time INTEGER, + is_unique INTEGER DEFAULT 0, + unique_hash TEXT UNIQUE, + k1 TEXT, + open_time INTEGER, + used INTEGER DEFAULT 0, + usescsv TEXT + ); + """ + ) + + +async def m002_change_withdraw_table(db): + """ + Creates an improved withdraw table and migrates the existing data. + """ + await db.execute( + f""" + CREATE TABLE withdraw.withdraw_link ( + id TEXT PRIMARY KEY, + wallet TEXT, + title TEXT, + min_withdrawable {db.big_int} DEFAULT 1, + max_withdrawable {db.big_int} DEFAULT 1, + uses INTEGER DEFAULT 1, + wait_time INTEGER, + is_unique INTEGER DEFAULT 0, + unique_hash TEXT UNIQUE, + k1 TEXT, + open_time INTEGER, + used INTEGER DEFAULT 0, + usescsv TEXT + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM withdraw.withdraw_links") + ]: + usescsv = "" + + for i in range(row[5]): + if row[7]: + usescsv += "," + str(i + 1) + else: + usescsv += "," + str(1) + usescsv = usescsv[1:] + await db.execute( + """ + INSERT INTO withdraw.withdraw_link ( + id, + wallet, + title, + min_withdrawable, + max_withdrawable, + uses, + wait_time, + is_unique, + unique_hash, + k1, + open_time, + used, + usescsv + ) + 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], + usescsv, + ), + ) + await db.execute("DROP TABLE withdraw.withdraw_links") + + +async def m003_make_hash_check(db): + """ + Creates a hash check table. + """ + await db.execute( + """ + CREATE TABLE withdraw.hash_check ( + id TEXT PRIMARY KEY, + lnurl_id TEXT + ); + """ + ) + + +async def m004_webhook_url(db): + """ + Adds webhook_url + """ + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;") + + +async def m005_add_custom_print_design(db): + """ + Adds custom print design + """ + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN custom_url TEXT;") + + +async def m006_webhook_headers_and_body(db): + """ + Add headers and body to webhooks + """ + await db.execute( + "ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;" + ) + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;") diff --git a/models.py b/models.py new file mode 100644 index 0000000..49421a7 --- /dev/null +++ b/models.py @@ -0,0 +1,79 @@ +import shortuuid +from fastapi import Query +from lnurl import Lnurl, LnurlWithdrawResponse +from lnurl import encode as lnurl_encode +from lnurl.models import ClearnetUrl, MilliSatoshi +from pydantic import BaseModel +from starlette.requests import Request + + +class CreateWithdrawData(BaseModel): + title: str = Query(...) + min_withdrawable: int = Query(..., ge=1) + max_withdrawable: int = Query(..., ge=1) + uses: int = Query(..., ge=1) + wait_time: int = Query(..., ge=1) + is_unique: bool + webhook_url: str = Query(None) + webhook_headers: str = Query(None) + webhook_body: str = Query(None) + custom_url: str = Query(None) + + +class WithdrawLink(BaseModel): + id: str + wallet: str = Query(None) + title: str = Query(None) + min_withdrawable: int = Query(0) + max_withdrawable: int = Query(0) + uses: int = Query(0) + wait_time: int = Query(0) + is_unique: bool = Query(False) + unique_hash: str = Query(0) + k1: str = Query(None) + open_time: int = Query(0) + used: int = Query(0) + usescsv: str = Query(None) + number: int = Query(0) + webhook_url: str = Query(None) + webhook_headers: str = Query(None) + webhook_body: str = Query(None) + custom_url: str = Query(None) + + @property + def is_spent(self) -> bool: + return self.used >= self.uses + + def lnurl(self, req: Request) -> Lnurl: + if self.is_unique: + usescssv = self.usescsv.split(",") + tohash = self.id + self.unique_hash + usescssv[self.number] + multihash = shortuuid.uuid(name=tohash) + url = req.url_for( + "withdraw.api_lnurl_multi_response", + unique_hash=self.unique_hash, + id_unique_hash=multihash, + ) + else: + url = req.url_for( + "withdraw.api_lnurl_response", unique_hash=self.unique_hash + ) + + return lnurl_encode(url) + + def lnurl_response(self, req: Request) -> LnurlWithdrawResponse: + url = req.url_for( + name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash + ) + return LnurlWithdrawResponse( + callback=ClearnetUrl(url, scheme="https"), + k1=self.k1, + minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000), + maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000), + defaultDescription=self.title, + ) + + +class HashCheck(BaseModel): + hash: bool + lnurl: bool diff --git a/static/image/lnurl-withdraw.png b/static/image/lnurl-withdraw.png new file mode 100644 index 0000000..4f03642 Binary files /dev/null and b/static/image/lnurl-withdraw.png differ diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..ced7843 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,323 @@ +/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ + +Vue.component(VueQrcode.name, VueQrcode) + +var locationPath = [ + window.location.protocol, + '//', + window.location.host, + window.location.pathname +].join('') + +var mapWithdrawLink = function (obj) { + obj._data = _.clone(obj) + obj.date = Quasar.utils.date.formatDate( + new Date(obj.time * 1000), + 'YYYY-MM-DD HH:mm' + ) + obj.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable) + obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable) + obj.uses_left = obj.uses - obj.used + obj.print_url = [locationPath, 'print/', obj.id].join('') + obj.withdraw_url = [locationPath, obj.id].join('') + obj._data.use_custom = Boolean(obj.custom_url) + return obj +} + +const CUSTOM_URL = '/static/images/default_voucher.png' + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + checker: null, + withdrawLinks: [], + withdrawLinksTable: { + columns: [ + {name: 'id', align: 'left', label: 'ID', field: 'id'}, + {name: 'title', align: 'left', label: 'Title', field: 'title'}, + { + name: 'wait_time', + align: 'right', + label: 'Wait', + field: 'wait_time' + }, + { + name: 'uses_left', + align: 'right', + label: 'Uses left', + field: 'uses_left' + }, + {name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'}, + {name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'} + ], + pagination: { + rowsPerPage: 10 + } + }, + nfcTagWriting: false, + formDialog: { + show: false, + secondMultiplier: 'seconds', + secondMultiplierOptions: ['seconds', 'minutes', 'hours'], + data: { + is_unique: false, + use_custom: false, + has_webhook: false + } + }, + simpleformDialog: { + show: false, + data: { + is_unique: true, + use_custom: false, + title: 'Vouchers', + min_withdrawable: 0, + wait_time: 1 + } + }, + qrCodeDialog: { + show: false, + data: null + } + } + }, + computed: { + sortedWithdrawLinks: function () { + return this.withdrawLinks.sort(function (a, b) { + return b.uses_left - a.uses_left + }) + } + }, + methods: { + getWithdrawLinks: function () { + var self = this + + LNbits.api + .request( + 'GET', + '/withdraw/api/v1/links?all_wallets=true', + this.g.user.wallets[0].inkey + ) + .then(function (response) { + self.withdrawLinks = response.data.map(function (obj) { + return mapWithdrawLink(obj) + }) + }) + .catch(function (error) { + clearInterval(self.checker) + LNbits.utils.notifyApiError(error) + }) + }, + closeFormDialog: function () { + this.formDialog.data = { + is_unique: false, + use_custom: false + } + }, + simplecloseFormDialog: function () { + this.simpleformDialog.data = { + is_unique: false, + use_custom: false + } + }, + openQrCodeDialog: function (linkId) { + var link = _.findWhere(this.withdrawLinks, {id: linkId}) + + this.qrCodeDialog.data = _.clone(link) + this.qrCodeDialog.data.url = + window.location.protocol + '//' + window.location.host + this.qrCodeDialog.show = true + }, + openUpdateDialog: function (linkId) { + var link = _.findWhere(this.withdrawLinks, {id: linkId}) + this.formDialog.data = _.clone(link._data) + this.formDialog.show = true + }, + sendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + + if (!data.use_custom) { + data.custom_url = null + } + + if (data.use_custom && !data?.custom_url) { + data.custom_url = CUSTOM_URL + } + + data.wait_time = + data.wait_time * + { + seconds: 1, + minutes: 60, + hours: 3600 + }[this.formDialog.secondMultiplier] + if (data.id) { + this.updateWithdrawLink(wallet, data) + } else { + this.createWithdrawLink(wallet, data) + } + }, + simplesendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { + id: this.simpleformDialog.data.wallet + }) + var data = _.omit(this.simpleformDialog.data, 'wallet') + + data.wait_time = 1 + data.min_withdrawable = data.max_withdrawable + data.title = 'vouchers' + data.is_unique = true + + if (!data.use_custom) { + data.custom_url = null + } + + if (data.use_custom && !data?.custom_url) { + data.custom_url = '/static/images/default_voucher.png' + } + + if (data.id) { + this.updateWithdrawLink(wallet, data) + } else { + this.createWithdrawLink(wallet, data) + } + }, + updateWithdrawLink: function (wallet, data) { + var self = this + const body = _.pick( + data, + 'title', + 'min_withdrawable', + 'max_withdrawable', + 'uses', + 'wait_time', + 'is_unique', + 'webhook_url', + 'webhook_headers', + 'webhook_body', + 'custom_url' + ) + + if (data.has_webhook) { + body = { + ...body, + webhook_url: data.webhook_url, + webhook_headers: data.webhook_headers, + webhook_body: data.webhook_body + } + } + + LNbits.api + .request( + 'PUT', + '/withdraw/api/v1/links/' + data.id, + wallet.adminkey, + body + ) + .then(function (response) { + self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { + return obj.id === data.id + }) + self.withdrawLinks.push(mapWithdrawLink(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + createWithdrawLink: function (wallet, data) { + var self = this + + LNbits.api + .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) + .then(function (response) { + self.withdrawLinks.push(mapWithdrawLink(response.data)) + self.formDialog.show = false + self.simpleformDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteWithdrawLink: function (linkId) { + var self = this + var link = _.findWhere(this.withdrawLinks, {id: linkId}) + + LNbits.utils + .confirmDialog('Are you sure you want to delete this withdraw link?') + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/withdraw/api/v1/links/' + linkId, + _.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey + ) + .then(function (response) { + self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { + return obj.id === linkId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + }, + 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-withdraw 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.' + }) + } + }, + exportCSV() { + LNbits.utils.exportCSV( + this.withdrawLinksTable.columns, + this.withdrawLinks, + 'withdraw-links' + ) + } + }, + created: function () { + if (this.g.user.wallets.length) { + var getWithdrawLinks = this.getWithdrawLinks + getWithdrawLinks() + this.checker = setInterval(function () { + getWithdrawLinks() + }, 300000) + } + } +}) diff --git a/templates/withdraw/_api_docs.html b/templates/withdraw/_api_docs.html new file mode 100644 index 0000000..ff88189 --- /dev/null +++ b/templates/withdraw/_api_docs.html @@ -0,0 +1,204 @@ + + + + + + GET /withdraw/api/v1/links +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<withdraw_link_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}withdraw/api/v1/links -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /withdraw/api/v1/links/<withdraw_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 + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST /withdraw/api/v1/links +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>, + "webhook_url": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}withdraw/api/v1/links -d + '{"title": <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>, + "webhook_url": <string>}' -H "Content-type: application/json" -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /withdraw/api/v1/links/<withdraw_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"title": <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>} +
+ Returns 200 OK (application/json) +
+ {"lnurl": <string>} +
Curl example
+ curl -X PUT {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -d '{"title": + <string>, "min_withdrawable": <integer>, + "max_withdrawable": <integer>, "uses": <integer>, + "wait_time": <integer>, "is_unique": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + DELETE + /withdraw/api/v1/links/<withdraw_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+ + + + GET + /withdraw/api/v1/links/<the_hash>/<lnurl_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ {"status": <bool>} +
Curl example
+ curl -X GET {{ request.base_url + }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /withdraw/img/<lnurl_id> +
Curl example
+ curl -X GET {{ request.base_url }}withdraw/img/<lnurl_id>" + +
+
+
+
diff --git a/templates/withdraw/_lnurl.html b/templates/withdraw/_lnurl.html new file mode 100644 index 0000000..f6b5205 --- /dev/null +++ b/templates/withdraw/_lnurl.html @@ -0,0 +1,32 @@ + + + +

+ 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 withdraw is the permission for + someone to pull a certain amount of funds from a lightning wallet. In + this extension time is also added - an amount can be withdraw over a + period of time. A typical use case for an LNURL withdraw is a faucet, + although it is a very powerful technology, with much further reaching + implications. For example, an LNURL withdraw could be minted to pay for + a subscription service. +

+

+ 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/withdraw/csv.html b/templates/withdraw/csv.html new file mode 100644 index 0000000..6290290 --- /dev/null +++ b/templates/withdraw/csv.html @@ -0,0 +1,12 @@ +{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes +in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor +%} {% endblock %} {% block scripts %} + +{% endblock %} diff --git a/templates/withdraw/display.html b/templates/withdraw/display.html new file mode 100644 index 0000000..3ef545c --- /dev/null +++ b/templates/withdraw/display.html @@ -0,0 +1,68 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+ {% if link.is_spent %} + Withdraw is spent. + {% endif %} + + + + + + +
+
+ Copy LNURL + +
+
+
+
+
+ + +
+ LNbits LNURL-withdraw link +
+

+ Use a LNURL compatible bitcoin wallet to claim the sats. +

+
+ + + {% include "withdraw/_lnurl.html" %} + +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html new file mode 100644 index 0000000..3ae244e --- /dev/null +++ b/templates/withdraw/index.html @@ -0,0 +1,471 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} {% block page %} +
+
+ + + Quick vouchers + Advanced withdraw link(s) + + + + + +
+
+
Withdraw links
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURL-withdraw extension +
+
+ + + + {% include "withdraw/_api_docs.html" %} + + {% include "withdraw/_lnurl.html" %} + + +
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + Use a custom voucher design + You can use an LNbits voucher design or a custom + one + + + + + + + + + + + Use unique withdraw QR codes to reduce `assmilking` + + This is recommended if you are sharing the links on social + media or print QR codes. + + + +
+ Update withdraw link + Create withdraw link + Cancel +
+
+
+
+ + + + + + + + + + + + + + + Use a custom voucher design + You can use an LNbits voucher design or a custom + one + + + + + +
+ Create vouchers + Cancel +
+
+
+
+ + + + + + {% raw %} + +

+ ID: {{ qrCodeDialog.data.id }}
+ Unique: {{ qrCodeDialog.data.is_unique }} + (QR code will change after each withdrawal)
+ Max. withdrawable: {{ + qrCodeDialog.data.max_withdrawable }} sat
+ Wait time: {{ qrCodeDialog.data.wait_time }} seconds
+ Withdraws: {{ qrCodeDialog.data.used }} / {{ + qrCodeDialog.data.uses }} + +

+ {% endraw %} +
+ Copy LNURL + Copy sharable link + + Write to NFC + Print + Close +
+
+
+
+{% endblock %} diff --git a/templates/withdraw/print_qr.html b/templates/withdraw/print_qr.html new file mode 100644 index 0000000..df4ca7d --- /dev/null +++ b/templates/withdraw/print_qr.html @@ -0,0 +1,71 @@ +{% extends "print.html" %} {% block page %} + +
+
+ {% for page in link %} + + + {% for threes in page %} + + {% for one in threes %} + + {% endfor %} + + {% endfor %} +
+
+ +
+
+
+ {% endfor %} +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/templates/withdraw/print_qr_custom.html b/templates/withdraw/print_qr_custom.html new file mode 100644 index 0000000..ca47cec --- /dev/null +++ b/templates/withdraw/print_qr_custom.html @@ -0,0 +1,113 @@ +{% extends "print.html" %} {% block page %} + +
+
+ {% for page in link %} + + {% for one in page %} +
+ ... + {{ amt }} sats +
+ +
+
+ {% endfor %} +
+ {% endfor %} +
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..e8e5719 --- /dev/null +++ b/views.py @@ -0,0 +1,149 @@ +from http import HTTPStatus +from io import BytesIO + +import pyqrcode +from fastapi import Depends, HTTPException, Request +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse, StreamingResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import withdraw_ext, withdraw_renderer +from .crud import chunks, get_withdraw_link + +templates = Jinja2Templates(directory="templates") + + +@withdraw_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return withdraw_renderer().TemplateResponse( + "withdraw/index.html", {"request": request, "user": user.dict()} + ) + + +@withdraw_ext.get("/{link_id}", response_class=HTMLResponse) +async def display(request: Request, link_id): + link = await get_withdraw_link(link_id, 0) + + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + return withdraw_renderer().TemplateResponse( + "withdraw/display.html", + { + "request": request, + "link": link.dict(), + "lnurl": link.lnurl(req=request), + "unique": True, + }, + ) + + +@withdraw_ext.get("/img/{link_id}", response_class=StreamingResponse) +async def img(request: Request, link_id): + link = await get_withdraw_link(link_id, 0) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + qr = pyqrcode.create(link.lnurl(request)) + 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", + }, + ) + + +@withdraw_ext.get("/print/{link_id}", response_class=HTMLResponse) +async def print_qr(request: Request, link_id): + link = await get_withdraw_link(link_id) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + # response.status_code = HTTPStatus.NOT_FOUND + # return "Withdraw link does not exist." + + if link.uses == 0: + + return withdraw_renderer().TemplateResponse( + "withdraw/print_qr.html", + {"request": request, "link": link.dict(), "unique": False}, + ) + links = [] + count = 0 + + for x in link.usescsv.split(","): + linkk = await get_withdraw_link(link_id, count) + if not linkk: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + links.append(str(linkk.lnurl(request))) + count = count + 1 + page_link = list(chunks(links, 2)) + linked = list(chunks(page_link, 5)) + + if link.custom_url: + return withdraw_renderer().TemplateResponse( + "withdraw/print_qr_custom.html", + { + "request": request, + "link": page_link, + "unique": True, + "custom_url": link.custom_url, + "amt": link.max_withdrawable, + }, + ) + + return withdraw_renderer().TemplateResponse( + "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} + ) + + +@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse) +async def csv(request: Request, link_id): + link = await get_withdraw_link(link_id) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + # response.status_code = HTTPStatus.NOT_FOUND + # return "Withdraw link does not exist." + + if link.uses == 0: + + return withdraw_renderer().TemplateResponse( + "withdraw/csv.html", + {"request": request, "link": link.dict(), "unique": False}, + ) + links = [] + count = 0 + + for x in link.usescsv.split(","): + linkk = await get_withdraw_link(link_id, count) + if not linkk: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + ) + links.append(str(linkk.lnurl(request))) + count = count + 1 + page_link = list(chunks(links, 2)) + linked = list(chunks(page_link, 5)) + + return withdraw_renderer().TemplateResponse( + "withdraw/csv.html", {"request": request, "link": linked, "unique": True} + ) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..525796c --- /dev/null +++ b/views_api.py @@ -0,0 +1,128 @@ +from http import HTTPStatus + +from fastapi import Depends, HTTPException, Query, Request +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key + +from . import withdraw_ext +from .crud import ( + create_withdraw_link, + delete_withdraw_link, + get_hash_check, + get_withdraw_link, + get_withdraw_links, + update_withdraw_link, +) +from .models import CreateWithdrawData + + +@withdraw_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_withdraw_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.", + ) + + +@withdraw_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_retrieve( + link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type) +): + link = await get_withdraw_link(link_id, 0) + + if not link: + raise HTTPException( + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN + ) + return {**link.dict(), **{"lnurl": link.lnurl(request)}} + + +@withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) +@withdraw_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_create_or_update( + req: Request, + data: CreateWithdrawData, + link_id: str = Query(None), + wallet: WalletTypeInfo = Depends(require_admin_key), +): + if data.uses > 250: + raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) + + if data.min_withdrawable < 1: + raise HTTPException( + detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST + ) + + if data.max_withdrawable < data.min_withdrawable: + raise HTTPException( + detail="`max_withdrawable` needs to be at least `min_withdrawable`.", + status_code=HTTPStatus.BAD_REQUEST, + ) + + if link_id: + link = await get_withdraw_link(link_id, 0) + if not link: + raise HTTPException( + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN + ) + link = await update_withdraw_link(link_id, **data.dict()) + else: + link = await create_withdraw_link(wallet_id=wallet.wallet.id, data=data) + assert link + return {**link.dict(), **{"lnurl": link.lnurl(req)}} + + +@withdraw_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) +async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): + link = await get_withdraw_link(link_id) + + if not link: + raise HTTPException( + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND + ) + + if link.wallet != wallet.wallet.id: + raise HTTPException( + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN + ) + + await delete_withdraw_link(link_id) + return {"success": True} + + +@withdraw_ext.get( + "/api/v1/links/{the_hash}/{lnurl_id}", + status_code=HTTPStatus.OK, + dependencies=[Depends(get_key_type)], +) +async def api_hash_retrieve(the_hash, lnurl_id): + hashCheck = await get_hash_check(the_hash, lnurl_id) + return hashCheck