From fcb973b540f311b583c48dc79e7303af19272a5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Fri, 17 Feb 2023 11:57:27 +0100 Subject: [PATCH] init commit --- .github/workflows/release.yml | 19 + .gitignore | 1 + README.md | 48 +++ __init__.py | 27 ++ config.json | 6 + crud.py | 173 +++++++++ lnurl.py | 200 ++++++++++ manifest.json | 9 + migrations.py | 134 +++++++ models.py | 79 ++++ static/image/lnurl-withdraw.png | Bin 0 -> 13081 bytes static/js/index.js | 323 ++++++++++++++++ templates/withdraw/_api_docs.html | 204 ++++++++++ templates/withdraw/_lnurl.html | 32 ++ templates/withdraw/csv.html | 12 + templates/withdraw/display.html | 68 ++++ templates/withdraw/index.html | 471 ++++++++++++++++++++++++ templates/withdraw/print_qr.html | 71 ++++ templates/withdraw/print_qr_custom.html | 113 ++++++ views.py | 149 ++++++++ views_api.py | 128 +++++++ 21 files changed, 2267 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 lnurl.py create mode 100644 manifest.json create mode 100644 migrations.py create mode 100644 models.py create mode 100644 static/image/lnurl-withdraw.png create mode 100644 static/js/index.js create mode 100644 templates/withdraw/_api_docs.html create mode 100644 templates/withdraw/_lnurl.html create mode 100644 templates/withdraw/csv.html create mode 100644 templates/withdraw/display.html create mode 100644 templates/withdraw/index.html create mode 100644 templates/withdraw/print_qr.html create mode 100644 templates/withdraw/print_qr_custom.html create mode 100644 views.py create mode 100644 views_api.py 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 0000000000000000000000000000000000000000..4f036423ab12fa866ac401fdac0b6e692847f70f GIT binary patch literal 13081 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 literal 0 HcmV?d00001 diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..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