diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 4e02f97..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: lint -on: - push: - branches: - - main - pull_request: - -jobs: - lint: - uses: lnbits/lnbits/.github/workflows/lint.yml@dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b76c725..7ec9b48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,14 @@ on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' + - "v[0-9]+.[0-9]+.[0-9]+" jobs: + release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 - name: Create github release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -19,7 +20,7 @@ jobs: needs: [release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 with: token: ${{ secrets.EXT_GITHUB }} repository: lnbits/lnbits-extensions @@ -33,12 +34,12 @@ jobs: - name: Create pull request in extensions repo env: GH_TOKEN: ${{ secrets.EXT_GITHUB }} - repo_name: '${{ github.event.repository.name }}' - tag: '${{ github.ref_name }}' - branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}' - title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}' - body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}' - archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip' + repo_name: "${{ github.event.repository.name }}" + tag: "${{ github.ref_name }}" + branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" + title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}" + body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}" + archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip" run: | cd lnbits-extensions git checkout -b $branch diff --git a/.gitignore b/.gitignore index 0152b6e..bee8a64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ __pycache__ -node_modules -.mypy_cache -.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 4746a3f..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,27 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - id: check-docstring-first - - id: check-json - - id: debug-statements - - id: mixed-line-ending - - id: check-case-conflict - - repo: https://github.com/psf/black - rev: 24.2.0 - hooks: - - id: black - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.2 - hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] - - repo: https://github.com/pre-commit/mirrors-prettier - rev: 'v4.0.0-alpha.8' - hooks: - - id: prettier - types_or: [css, javascript, html, json] diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 725c398..0000000 --- a/.prettierrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "semi": false, - "arrowParens": "avoid", - "insertPragma": false, - "printWidth": 80, - "proseWrap": "preserve", - "singleQuote": true, - "trailingComma": "none", - "useTabs": false, - "bracketSameLine": false, - "bracketSpacing": false -} diff --git a/Makefile b/Makefile deleted file mode 100644 index 0fac253..0000000 --- a/Makefile +++ /dev/null @@ -1,47 +0,0 @@ -all: format check - -format: prettier black ruff - -check: mypy pyright checkblack checkruff checkprettier - -prettier: - uv run ./node_modules/.bin/prettier --write . -pyright: - uv run ./node_modules/.bin/pyright - -mypy: - uv run mypy . - -black: - uv run black . - -ruff: - uv run ruff check . --fix - -checkruff: - uv run ruff check . - -checkprettier: - uv run ./node_modules/.bin/prettier --check . - -checkblack: - uv run black --check . - -checkeditorconfig: - editorconfig-checker - -test: - PYTHONUNBUFFERED=1 \ - DEBUG=true \ - uv run pytest -install-pre-commit-hook: - @echo "Installing pre-commit hook to git" - @echo "Uninstall the hook with uv run pre-commit uninstall" - uv run pre-commit install - -pre-commit: - uv run pre-commit run --all-files - - -checkbundle: - @echo "skipping checkbundle" diff --git a/README.md b/README.md index a1b5bc6..497f3de 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # LNURLw - [LNbits](https://github.com/lnbits/lnbits) extension - For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) ## Create a static QR code people can use to withdraw funds from a Lightning Network wallet diff --git a/__init__.py b/__init__.py index 159e280..f7f4545 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,13 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request, Response +from fastapi.routing import APIRoute -from .crud import db -from .views import withdraw_ext_generic -from .views_api import withdraw_ext_api -from .views_lnurl import withdraw_ext_lnurl +from fastapi.responses import JSONResponse + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from typing import Callable + +db = Database("ext_withdraw") withdraw_static_files = [ { @@ -12,56 +16,36 @@ withdraw_static_files = [ } ] + +class LNURLErrorResponseHandler(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + try: + response = await original_route_handler(request) + except HTTPException as exc: + logger.debug(f"HTTPException: {exc}") + response = JSONResponse( + status_code=exc.status_code, + content={"status": "ERROR", "reason": f"{exc.detail}"}, + ) + except Exception as exc: + raise exc + + return response + + return custom_route_handler + + withdraw_ext: APIRouter = APIRouter(prefix="/withdraw", tags=["withdraw"]) -withdraw_ext.include_router(withdraw_ext_generic) -withdraw_ext.include_router(withdraw_ext_api) -withdraw_ext.include_router(withdraw_ext_lnurl) +withdraw_ext.route_class = LNURLErrorResponseHandler -def withdraw_start() -> None: - """ - Register this extension's RPCs with the LNbits nostr transport so an - HTTP-allergic client (e.g. lamassu-next ATM) can manage LNURL-withdraw - links without touching the HTTP API. Also wires the link-owner - resolver so subscribe_payments({tag:"withdraw", link_id:...}) can - verify ownership. - - No-op if the core transport module isn't present in the LNbits build. - No runtime `if nostr_transport_enabled` guard is needed — when - disabled, the relay pool never publishes, so registered RPCs are - simply unreachable. - """ - try: - from lnbits.core.services.nostr_transport.dispatcher import ( - AUTH_ACCOUNT, - AUTH_WALLET, - register_rpc, - ) - from lnbits.core.services.nostr_transport.subscriptions import ( - register_link_owner_resolver, - ) - except ImportError: - return - - from .transport_rpcs import ( - handle_lnurlw_create_link, - handle_lnurlw_delete_link, - handle_lnurlw_get_link, - handle_lnurlw_list_links, - handle_lnurlw_unique_hashes, - handle_lnurlw_update_link, - resolve_withdraw_owner, - ) - - register_rpc("lnurlw_create_link", handle_lnurlw_create_link, AUTH_WALLET) - register_rpc("lnurlw_get_link", handle_lnurlw_get_link, AUTH_WALLET) - register_rpc("lnurlw_list_links", handle_lnurlw_list_links, AUTH_ACCOUNT) - register_rpc("lnurlw_unique_hashes", handle_lnurlw_unique_hashes, AUTH_WALLET) - register_rpc("lnurlw_update_link", handle_lnurlw_update_link, AUTH_WALLET) - register_rpc("lnurlw_delete_link", handle_lnurlw_delete_link, AUTH_WALLET) - register_link_owner_resolver( - "withdraw", resolve_withdraw_owner, link_extra_key="withdrawal_link_id" - ) +def withdraw_renderer(): + return template_renderer(["withdraw/templates"]) -__all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"] +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 index e54edf5..49a66e7 100644 --- a/config.json +++ b/config.json @@ -1,80 +1,7 @@ { - "name": "Withdraw Links", + "name": "LNURLw", "short_description": "Make LNURL withdraw links", "tile": "/withdraw/static/image/lnurl-withdraw.png", - "version": "1.2.2-aio.2", - "min_lnbits_version": "1.3.0", - "contributors": [ - { - "name": "arcbtc", - "uri": "https://github.com/arcbtc", - "role": "Developer" - }, - { - "name": "talvasconcelos", - "uri": "https://github.com/talvasconcelos", - "role": "Developer" - }, - { - "name": "eillarra", - "uri": "https://github.com/eillarra", - "role": "Developer" - }, - { - "name": "dni", - "uri": "https://github.com/dni", - "role": "Developer" - }, - { - "name": "motorina0", - "uri": "https://github.com/motorina0", - "role": "Developer" - }, - { - "name": "prusnak", - "uri": "https://github.com/prusnak", - "role": "Developer" - }, - { - "name": "callebtc", - "uri": "https://github.com/callebtc", - "role": "Developer" - }, - { - "name": "Liongrass", - "uri": "https://github.com/Liongrass", - "role": "Developer" - }, - { - "name": "supiiik", - "uri": "https://github.com/supiiik", - "role": "Developer" - }, - { - "name": "Jakub-Dv", - "uri": "https://github.com/Jakub-Dv", - "role": "Developer" - } - ], - "images": [ - { - "uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/1.jpg", - "link": "https://www.youtube.com/embed/TUmsHpJtveQ?si=3_l1cg0JC8CXHtYf" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/1.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/2.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/3.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/withdraw/main/static/image/4.png" - } - ], - "description_md": "https://raw.githubusercontent.com/lnbits/withdraw/main/description.md", - "terms_and_conditions_md": "https://raw.githubusercontent.com/withdraw/lnurldevice/main/toc.md", - "license": "MIT" + "contributors": ["arcbtc", "eillarra"], + "min_lnbits_version": "0.11.0" } diff --git a/crud.py b/crud.py index 73966a5..c28848b 100644 --- a/crud.py +++ b/crud.py @@ -1,12 +1,12 @@ from datetime import datetime +from typing import List, Optional, Union import shortuuid -from lnbits.db import Database + from lnbits.helpers import urlsafe_short_hash -from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink - -db = Database("ext_withdraw") +from . import db +from .models import CreateWithdrawData, HashCheck, WithdrawLink async def create_withdraw_link( @@ -14,87 +14,87 @@ async def create_withdraw_link( ) -> WithdrawLink: link_id = urlsafe_short_hash()[:22] available_links = ",".join([str(i) for i in range(data.uses)]) - withdraw_link = WithdrawLink( - id=link_id, - wallet=wallet_id, - unique_hash=urlsafe_short_hash(), - k1=urlsafe_short_hash(), - created_at=datetime.now(), - open_time=int(datetime.now().timestamp()) + data.wait_time, - title=data.title, - min_withdrawable=data.min_withdrawable, - max_withdrawable=data.max_withdrawable, - uses=data.uses, - wait_time=data.wait_time, - is_unique=data.is_unique, - usescsv=available_links, - webhook_url=data.webhook_url, - webhook_headers=data.webhook_headers, - webhook_body=data.webhook_body, - custom_url=data.custom_url, - extra=data.extra, - number=0, + 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, + ), ) - await db.insert("withdraw.withdraw_link", withdraw_link) - return withdraw_link - - -async def get_withdraw_link(link_id: str, num=0) -> WithdrawLink | None: - link = await db.fetchone( - "SELECT * FROM withdraw.withdraw_link WHERE id = :id", - {"id": link_id}, - WithdrawLink, - ) - if not link: - return None - - link.number = num + link = await get_withdraw_link(link_id, 0) + assert link, "Newly created link couldn't be retrieved" return link -async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> WithdrawLink | None: - link = await db.fetchone( - "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = :hash", - {"hash": unique_hash}, - WithdrawLink, +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 link: + if not row: return None - link.number = num - return link + link = dict(**row) + link["number"] = num + + return WithdrawLink.parse_obj(link) -async def get_withdraw_links( - wallet_ids: list[str], limit: int, offset: int -) -> PaginatedWithdraws: - q = ",".join([f"'{w}'" for w in wallet_ids]) - - query_str = f""" - SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q}) - ORDER BY open_time DESC - """ - - if limit > 0: - query_str += """ LIMIT :limit OFFSET :offset""" - query_params = {"limit": limit, "offset": offset} - else: - query_params = {} - - links = await db.fetchall( - query_str, - query_params, - WithdrawLink, +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,) ) - result = await db.execute( - f""" - SELECT COUNT(*) as total FROM withdraw.withdraw_link - WHERE wallet IN ({q}) - """ - ) - result2 = result.mappings().first() + if not row: + return None - return PaginatedWithdraws(data=links, total=int(result2.total)) + 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}) ORDER BY open_time DESC", (*wallet_ids,) + ) + return [WithdrawLink(**row) for row in rows] async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: @@ -103,25 +103,36 @@ async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> N for x in link.usescsv.split(",") if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) ] - link.usescsv = ",".join(unique_links) - await update_withdraw_link(link) + await update_withdraw_link( + link.id, + usescsv=",".join(unique_links), + ) async def increment_withdraw_link(link: WithdrawLink) -> None: - link.used = link.used + 1 - link.open_time = int(datetime.now().timestamp()) - await update_withdraw_link(link) + 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: WithdrawLink) -> WithdrawLink: - await db.update("withdraw.withdraw_link", link) - return link +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 = :id", {"id": link_id} - ) + await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) def chunks(lst, n): @@ -132,45 +143,31 @@ def chunks(lst, 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 (:id, :lnurl_id) + INSERT INTO withdraw.hash_check ( + id, + lnurl_id + ) + VALUES (?, ?) """, - {"id": the_hash, "lnurl_id": lnurl_id}, + (the_hash, lnurl_id), ) - hash_check = await get_hash_check(the_hash, lnurl_id) - return hash_check + hashCheck = await get_hash_check(the_hash, lnurl_id) + return hashCheck async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: - - hash_check = await db.fetchone( - """ - SELECT id as hash, lnurl_id as lnurl - FROM withdraw.hash_check WHERE id = :id - """, - {"id": the_hash}, - HashCheck, + rowid = await db.fetchone( + "SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,) ) - hash_check_lnurl = await db.fetchone( - """ - SELECT id as hash, lnurl_id as lnurl - FROM withdraw.hash_check WHERE lnurl_id = :id - """, - {"id": lnurl_id}, - HashCheck, + rowlnurl = await db.fetchone( + "SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,) ) - if not hash_check_lnurl: + if not rowlnurl: await create_hash_check(the_hash, lnurl_id) return HashCheck(lnurl=True, hash=False) else: - if not hash_check: + if not rowid: await create_hash_check(the_hash, lnurl_id) return HashCheck(lnurl=True, hash=False) else: return HashCheck(lnurl=True, hash=True) - - -async def delete_hash_check(the_hash: str) -> None: - await db.execute( - "DELETE FROM withdraw.hash_check WHERE id = :hash", {"hash": the_hash} - ) diff --git a/description.md b/description.md deleted file mode 100644 index d918031..0000000 --- a/description.md +++ /dev/null @@ -1,7 +0,0 @@ -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. diff --git a/helpers.py b/helpers.py deleted file mode 100644 index 31efcff..0000000 --- a/helpers.py +++ /dev/null @@ -1,54 +0,0 @@ -from fastapi import Request -from lnbits.settings import settings -from lnurl import Lnurl -from lnurl import encode as lnurl_encode -from shortuuid import uuid - -from .models import WithdrawLink - - -def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl: - if link.is_unique: - usescssv = link.usescsv.split(",") - tohash = link.id + link.unique_hash + usescssv[link.number] - multihash = uuid(name=tohash) - url = req.url_for( - "withdraw.api_lnurl_multi_response", - unique_hash=link.unique_hash, - id_unique_hash=multihash, - ) - else: - url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash) - - try: - return lnurl_encode(str(url)) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, " - "check your webserver proxy configuration." - ) from e - - -def create_lnurl_from_baseurl(link: WithdrawLink) -> Lnurl: - """ - Same shape as `create_lnurl`, but composes the callback URL from - `settings.lnbits_baseurl` instead of a FastAPI `Request`. Used by - the nostr-transport RPC handlers, which have no HTTP request to - derive a base URL from. - """ - base = settings.lnbits_baseurl.rstrip("/") - if link.is_unique: - usescssv = link.usescsv.split(",") - tohash = link.id + link.unique_hash + usescssv[link.number] - multihash = uuid(name=tohash) - url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}/{multihash}" - else: - url = f"{base}/withdraw/api/v1/lnurl/{link.unique_hash}" - - try: - return lnurl_encode(url) - except Exception as e: - raise ValueError( - f"Error creating LNURL with url: `{url!s}`, " - "check your `LNBITS_BASEURL` configuration." - ) from e diff --git a/lnurl.py b/lnurl.py new file mode 100644 index 0000000..5396d75 --- /dev/null +++ b/lnurl.py @@ -0,0 +1,212 @@ +import json +from datetime import datetime +from http import HTTPStatus +from urllib.parse import urlparse + +import httpx +import shortuuid +from fastapi import HTTPException, Query, Request +from fastapi.responses import JSONResponse +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=JSONResponse, + name="withdraw.api_lnurl_response", +) +async def api_lnurl_response(request: Request, unique_hash: str): + 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 = str(request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)) + + # Check if url is .onion and change to http + if urlparse(url).netloc.endswith(".onion"): + # change url string scheme to http + url = url.replace("https://", "http://") + + return { + "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, + } + + +@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_class=JSONResponse, + 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: + await increment_withdraw_link(link) + payment_hash = await pay_invoice( + wallet_id=link.wallet, + payment_request=pr, + max_sat=link.max_withdrawable, + extra={"tag": "withdraw"}, + ) + 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(f"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=JSONResponse, + name="withdraw.api_lnurl_multi_response", +) +async def api_lnurl_multi_response(request: Request, unique_hash: str, id_unique_hash: str): + 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 = str(request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)) + + # Check if url is .onion and change to http + if urlparse(url).netloc.endswith(".onion"): + # change url string scheme to http + url = url.replace("https://", "http://") + + return { + "tag": "withdrawRequest", + "callback": f"{url}?id_unique_hash={id_unique_hash}", + "k1": link.k1, + "minWithdrawable": link.min_withdrawable * 1000, + "maxWithdrawable": link.max_withdrawable * 1000, + "defaultDescription": link.title, + } diff --git a/manifest.json b/manifest.json index 32602d7..8153bea 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "repos": [ - { - "id": "withdraw", - "organisation": "lnbits", - "repository": "withdraw" - } - ] + "repos": [ + { + "id": "withdraw", + "organisation": "lnbits", + "repository": "withdraw" + } + ] } diff --git a/migrations.py b/migrations.py index e27af8a..95805ae 100644 --- a/migrations.py +++ b/migrations.py @@ -132,16 +132,3 @@ async def m006_webhook_headers_and_body(db): "ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_headers TEXT;" ) await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_body TEXT;") - - -async def m007_add_created_at_timestamp(db): - await db.execute( - "ALTER TABLE withdraw.withdraw_link " - f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}" - ) - - -async def m008_add_enabled_column(db): - await db.execute( - "ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;" - ) diff --git a/migrations_fork.py b/migrations_fork.py deleted file mode 100644 index 1caf27c..0000000 --- a/migrations_fork.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Fork-specific database migrations for the aiolabs withdraw extension. - -These migrations are tracked separately under `withdraw_fork` in the -`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`), -so they do not collide with upstream's `m{NNN}_*` numbering in -`migrations.py`. Keeping the upstream-tracked file untouched means -`git pull upstream` stays rebase-clean for schema changes. - -Conventions: - - Sequential numbering starting from m001. - - Each migration is `async def m{NNN}_(db)`. - - DDL must be idempotent: a fresh install runs every migration; an - install that already carries the column must not crash. Use - `_alter_add_column_safe` so re-runs are no-ops. -""" - - -async def _alter_add_column_safe(db, sql: str) -> None: - """ALTER TABLE ADD COLUMN that swallows duplicate-column errors, so a - re-run on a DB that already has the column is a silent no-op.""" - try: - await db.execute(sql) - except Exception as exc: - msg = str(exc).lower() - if "duplicate column" in msg or "already exists" in msg: - return - raise - - -async def m001_aio_withdraw_schema(db): - """ - Apply every aiolabs schema delta on top of upstream withdraw. - - `withdraw_link.extra` — arbitrary JSON merged into the payout payment's - `extra` when the link is claimed (see views_lnurl). Lets a caller tag the - resulting payment with settlement/attribution metadata an external listener - can key on — e.g. bitSpire stamps {source, type, principal_sats, fee_sats, - ...} so the spirekeeper cash-in settlement fires off an LNURL-withdraw - payout. Stored as TEXT; (de)serialized to a dict by the WithdrawLink model. - """ - await _alter_add_column_safe( - db, "ALTER TABLE withdraw.withdraw_link ADD COLUMN extra TEXT" - ) diff --git a/models.py b/models.py index 2f8ae48..f9c09f7 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,10 @@ -from datetime import datetime - +import shortuuid from fastapi import Query -from pydantic import BaseModel, Field +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): @@ -15,13 +18,6 @@ class CreateWithdrawData(BaseModel): webhook_headers: str = Query(None) webhook_body: str = Query(None) custom_url: str = Query(None) - enabled: bool = Query(True) - # Arbitrary JSON merged into the payout payment's `extra` when this link is - # claimed (see views_lnurl). Lets a caller tag the resulting payment with - # settlement/attribution metadata an external listener can key on — e.g. - # bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the - # spirekeeper cash-in settlement fires off an LNURL-withdraw payout. - extra: dict | None = None class WithdrawLink(BaseModel): @@ -38,43 +34,46 @@ class WithdrawLink(BaseModel): open_time: int = Query(0) used: int = Query(0) usescsv: str = Query(None) - number: int = Field(default=0, no_database=True) + number: int = Query(0) webhook_url: str = Query(None) webhook_headers: str = Query(None) webhook_body: str = Query(None) custom_url: str = Query(None) - # Persisted as TEXT (JSON); merged into the payout payment's `extra` on - # claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON - # natively (same as Payment.extra) — no per-field validator needed. - extra: dict | None = None - created_at: datetime - enabled: bool = Query(True) - lnurl: str | None = Field( - default=None, - no_database=True, - deprecated=True, - description=( - "Deprecated: Instead of using this bech32 encoded string, dynamically " - "generate your own static link (lud17/bech32) on the client side. " - "Example: lnurlw://${window.location.hostname}/lnurlw/${id}" - ), - ) - lnurl_url: str | None = Field( - default=None, - no_database=True, - description="The raw LNURL callback URL (use for QR code generation)", - ) @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 = str(req.url_for( + "withdraw.api_lnurl_multi_response", + unique_hash=self.unique_hash, + id_unique_hash=multihash, + )) + else: + url = str(req.url_for( + "withdraw.api_lnurl_response", unique_hash=self.unique_hash + )) + + return lnurl_encode(url) + + def lnurl_response(self, req: Request) -> LnurlWithdrawResponse: + url = str(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 - - -class PaginatedWithdraws(BaseModel): - data: list[WithdrawLink] - total: int diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 8bfa4db..0000000 --- a/package-lock.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "withdraw", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "withdraw", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "prettier": "^3.2.5", - "pyright": "^1.1.358" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pyright": { - "version": "1.1.359", - "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.359.tgz", - "integrity": "sha512-rtdQDlVfZy10MUDuTlY75wKaQt4hbd/kSAKHIJqaStZs4UPQMVrhpZBEDf1NQGAiSGCuKQn0qVpNNuGUEicqlQ==", - "bin": { - "pyright": "index.js", - "pyright-langserver": "langserver.index.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index f63f688..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "withdraw", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "", - "license": "ISC", - "dependencies": { - "prettier": "^3.2.5", - "pyright": "^1.1.358" - } -} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index a091367..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,88 +0,0 @@ -[project] -name = "lnbits-withdraw" -version = "0.0.0" -requires-python = ">=3.10,<3.13" -description = "LNbits, free and open-source Lightning wallet and accounts system." -authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] -urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" } -dependencies = [ "lnbits>1" ] - -[tool.poetry] -package-mode = false - -[tool.uv] -dev-dependencies = [ - "black", - "pytest-asyncio", - "pytest", - "mypy", - "pre-commit", - "ruff", - "pytest-md", -] - -[tool.mypy] -plugins = ["pydantic.mypy"] - -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = true -warn_untyped_fields = true - -[tool.pytest.ini_options] -log_cli = false -testpaths = [ - "tests" -] - -[tool.black] -line-length = 88 - -[tool.ruff] -# Same as Black. + 10% rule of black -line-length = 88 - -[tool.ruff.lint] -# Enable: -# F - pyflakes -# E - pycodestyle errors -# W - pycodestyle warnings -# I - isort -# A - flake8-builtins -# C - mccabe -# N - naming -# UP - pyupgrade -# RUF - ruff -# B - bugbear -select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] -# UP007: pyupgrade: use X | Y instead of Optional. (python3.10) -# C901 `api_link_create_or_update` is too complex (15 > 10) -ignore = ["UP007", "C901"] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -# needed for pydantic -[tool.ruff.lint.pep8-naming] -classmethod-decorators = [ - "root_validator", -] - -# Ignore unused imports in __init__.py files. -# [tool.ruff.lint.extend-per-file-ignores] -# "__init__.py" = ["F401", "F403"] - -# [tool.ruff.lint.mccabe] -# max-complexity = 10 - -[tool.ruff.lint.flake8-bugbear] -# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. -extend-immutable-calls = [ - "fastapi.Depends", - "fastapi.Query", -] diff --git a/static/image/1.jpg b/static/image/1.jpg deleted file mode 100644 index c7b9a87..0000000 Binary files a/static/image/1.jpg and /dev/null differ diff --git a/static/image/1.png b/static/image/1.png deleted file mode 100644 index 751ed1a..0000000 Binary files a/static/image/1.png and /dev/null differ diff --git a/static/image/2.png b/static/image/2.png deleted file mode 100644 index 1abfca3..0000000 Binary files a/static/image/2.png and /dev/null differ diff --git a/static/image/3.png b/static/image/3.png deleted file mode 100644 index 8d6c910..0000000 Binary files a/static/image/3.png and /dev/null differ diff --git a/static/image/4.png b/static/image/4.png deleted file mode 100644 index 565bfea..0000000 Binary files a/static/image/4.png and /dev/null differ diff --git a/static/js/index.js b/static/js/index.js index 0b42b40..3409993 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,33 +1,42 @@ -const mapWithdrawLink = function (obj) { +/* 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' -window.app = Vue.createApp({ +new Vue({ el: '#vue', - mixins: [window.windowMixin], - data() { + mixins: [windowMixin], + data: function () { return { checker: null, withdrawLinks: [], - lnurl: '', withdrawLinksTable: { columns: [ + {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'title', align: 'left', label: 'Title', field: 'title'}, - { - name: 'created_at', - align: 'left', - label: 'Created At', - field: 'created_at', - sortable: true, - format: function (val) { - return new Date(val).toLocaleString() - } - }, { name: 'wait_time', align: 'right', @@ -37,7 +46,7 @@ window.app = Vue.createApp({ { name: 'uses', align: 'right', - label: 'Uses', + label: 'Created', field: 'uses' }, { @@ -46,18 +55,11 @@ window.app = Vue.createApp({ label: 'Uses left', field: 'uses_left' }, - { - name: 'max_withdrawable', - align: 'right', - label: 'Max (sat)', - field: 'max_withdrawable', - format: LNbits.utils.formatSat - } + {name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'}, + {name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'} ], pagination: { - page: 1, - rowsPerPage: 10, - rowsNumber: 0 + rowsPerPage: 10 } }, nfcTagWriting: false, @@ -68,8 +70,7 @@ window.app = Vue.createApp({ data: { is_unique: false, use_custom: false, - has_webhook: false, - enabled: true + has_webhook: false } }, simpleformDialog: { @@ -79,8 +80,7 @@ window.app = Vue.createApp({ use_custom: false, title: 'Vouchers', min_withdrawable: 0, - wait_time: 1, - enabled: true + wait_time: 1 } }, qrCodeDialog: { @@ -90,71 +90,64 @@ window.app = Vue.createApp({ } }, computed: { - sortedWithdrawLinks() { + sortedWithdrawLinks: function () { return this.withdrawLinks.sort(function (a, b) { return b.uses_left - a.uses_left }) } }, methods: { - getWithdrawLinks(props) { - if (props) { - this.withdrawLinksTable.pagination = props.pagination - } - - let pagination = this.withdrawLinksTable.pagination - const query = { - limit: pagination.rowsPerPage, - offset: (pagination.page - 1) * pagination.rowsPerPage - } + getWithdrawLinks: function () { + var self = this LNbits.api .request( 'GET', - `/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`, + '/withdraw/api/v1/links?all_wallets=true', this.g.user.wallets[0].inkey ) - .then(response => { - this.withdrawLinks = response.data.data.map(mapWithdrawLink) - this.withdrawLinksTable.pagination.rowsNumber = response.data.total + .then(function (response) { + self.withdrawLinks = response.data.map(function (obj) { + return mapWithdrawLink(obj) + }) }) - .catch(error => { - clearInterval(this.checker) + .catch(function (error) { + clearInterval(self.checker) LNbits.utils.notifyApiError(error) }) }, - closeFormDialog() { + closeFormDialog: function () { this.formDialog.data = { is_unique: false, use_custom: false, - has_webhook: false, - enabled: true + has_webhook: false } }, - simplecloseFormDialog() { + simplecloseFormDialog: function () { this.simpleformDialog.data = { is_unique: false, - use_custom: false, - enabled: true + use_custom: false } }, - openQrCodeDialog(linkId) { - const link = _.findWhere(this.withdrawLinks, {id: linkId}) + 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 - this.activeUrl = link.lnurl_url }, - openUpdateDialog(linkId) { - let link = _.findWhere(this.withdrawLinks, {id: linkId}) + openUpdateDialog: function (linkId) { + var link = _.findWhere(this.withdrawLinks, {id: linkId}) link._data.has_webhook = link._data.webhook_url ? true : false this.formDialog.data = _.clone(link._data) this.formDialog.show = true }, - sendFormData() { - const wallet = _.findWhere(this.g.user.wallets, { + sendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) - const data = _.omit(this.formDialog.data, 'wallet') + var data = _.omit(this.formDialog.data, 'wallet') if (!data.use_custom) { data.custom_url = null @@ -178,11 +171,11 @@ window.app = Vue.createApp({ this.createWithdrawLink(wallet, data) } }, - simplesendFormData() { - const wallet = _.findWhere(this.g.user.wallets, { + simplesendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { id: this.simpleformDialog.data.wallet }) - const data = _.omit(this.simpleformDialog.data, 'wallet') + var data = _.omit(this.simpleformDialog.data, 'wallet') data.wait_time = 1 data.min_withdrawable = data.max_withdrawable @@ -203,7 +196,9 @@ window.app = Vue.createApp({ this.createWithdrawLink(wallet, data) } }, - updateWithdrawLink(wallet, data) { + updateWithdrawLink: function (wallet, data) { + var self = this + // Remove webhook info if toggle is set to false if (!data.has_webhook) { data.webhook_url = null @@ -218,45 +213,48 @@ window.app = Vue.createApp({ wallet.adminkey, data ) - .then(response => { - this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) { + .then((response) => { + self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { return obj.id === data.id }) - this.withdrawLinks.push(mapWithdrawLink(response.data)) - this.formDialog.show = false - this.closeFormDialog() - }) - .catch(error => { - LNbits.utils.notifyApiError(error) - }) - }, - createWithdrawLink(wallet, data) { - LNbits.api - .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) - .then(response => { - this.withdrawLinks.push(mapWithdrawLink(response.data)) - this.formDialog.show = false - this.simpleformDialog.show = false + self.withdrawLinks.push(mapWithdrawLink(response.data)) + self.formDialog.show = false this.closeFormDialog() }) .catch(function (error) { LNbits.utils.notifyApiError(error) }) }, - deleteWithdrawLink(linkId) { - const link = _.findWhere(this.withdrawLinks, {id: linkId}) + createWithdrawLink: function (wallet, data) { + var self = this + + LNbits.api + .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) + .then((response) => { + self.withdrawLinks.push(mapWithdrawLink(response.data)) + self.formDialog.show = false + self.simpleformDialog.show = false + this.closeFormDialog() + }) + .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(() => { + .onOk(function () { LNbits.api .request( 'DELETE', '/withdraw/api/v1/links/' + linkId, - _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey + _.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey ) - .then(() => { - this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) { + .then(function (response) { + self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { return obj.id === linkId }) }) @@ -265,7 +263,7 @@ window.app = Vue.createApp({ }) }) }, - async writeNfcTag(lnurl) { + writeNfcTag: async function (lnurl) { try { if (typeof NDEFReader == 'undefined') { throw { @@ -307,12 +305,15 @@ window.app = Vue.createApp({ this.withdrawLinks, 'withdraw-links' ) - } + }, }, - created() { + created: function () { if (this.g.user.wallets.length) { - this.getWithdrawLinks() - this.checker = setInterval(this.getWithdrawLinks, 300000) + 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 index dbd7bf8..ff88189 100644 --- a/templates/withdraw/_api_docs.html +++ b/templates/withdraw/_api_docs.html @@ -24,7 +24,7 @@
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links -H - "X-Api-Key: " + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -51,8 +51,8 @@
Curl example
curl -X GET {{ request.base_url - }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: - " + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -86,7 +86,7 @@ "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H "Content-type: application/json" -H - "X-Api-Key: " + "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -122,8 +122,8 @@ <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H - "Content-type: application/json" -H "X-Api-Key: - " + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -147,8 +147,8 @@
Curl example
curl -X DELETE {{ request.base_url - }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: - " + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -176,7 +176,7 @@ curl -X GET {{ request.base_url }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H - "X-Api-Key: " + "X-Api-Key: {{ user.wallets[0].inkey }}" 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 index 812c95f..3ef545c 100644 --- a/templates/withdraw/display.html +++ b/templates/withdraw/display.html @@ -4,32 +4,29 @@
- Withdraw is spent. - Withdraw is spent. - Withdraw is disabled. - - + {% if link.is_spent %} + Withdraw is spent. + {% endif %} + + + + +
- Copy LNURL
@@ -55,16 +52,15 @@ {% endblock %} {% block scripts %} +{% endblock %} {% block page %}
@@ -7,11 +9,7 @@ Quick vouchers - Advanced withdraw link(s) @@ -30,72 +28,77 @@ + {% raw %} + {% endraw %} @@ -139,7 +129,7 @@
- LNbits LNURL withdraw extension + {{SITE_TITLE}} LNURL-withdraw extension
@@ -252,20 +242,6 @@ hint="Custom data as JSON string, will get posted along with webhook 'body' field." > - - - - - - Enable / Disable - You can enable or disable these vouchers - - - - - - - - Enable / Disable - You can enable or disable these vouchers - - - + + + {% raw %} +

- ID:
- Unique: - - + ID: {{ qrCodeDialog.data.id }}
+ Unique: {{ qrCodeDialog.data.is_unique }} (QR code will change after each withdrawal)
- Max. withdrawable: - sat
- Wait time: - seconds
- Withdraws: - / -
+ 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 - Open sharable link - Print @@ -504,6 +468,4 @@
-{% endblock %}{% block scripts %} {{ window_vars(user) }} - {% endblock %} diff --git a/templates/withdraw/print_qr.html b/templates/withdraw/print_qr.html index 3b73b13..df4ca7d 100644 --- a/templates/withdraw/print_qr.html +++ b/templates/withdraw/print_qr.html @@ -4,21 +4,22 @@
{% for page in link %} -
- {% for row in page %} -
- {% for one in row %} -
- -
+ + {% for threes in page %} + + {% for one in threes %} + {% endfor %} - + {% endfor %} - +
+
+ +
+
{% endfor %}
@@ -52,9 +53,11 @@ {% endblock %} {% block scripts %}