diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..4e02f97 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,10 @@ +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 73594ff..b76c725 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,13 @@ -name: release github version 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@v3 + - uses: actions/checkout@v4 - name: Create github release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -21,7 +19,7 @@ jobs: needs: [release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: token: ${{ secrets.EXT_GITHUB }} repository: lnbits/lnbits-extensions @@ -35,12 +33,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 }}" - 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 bee8a64..0152b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ __pycache__ +node_modules +.mypy_cache +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4746a3f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..725c398 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..0fac253 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +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 497f3de..a1b5bc6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # 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 c9e0eb8..159e280 100644 --- a/__init__.py +++ b/__init__.py @@ -1,53 +1,67 @@ -from fastapi import APIRouter, Request, Response -from fastapi.routing import APIRoute +from fastapi import APIRouter -from fastapi.staticfiles import StaticFiles -from fastapi.responses import JSONResponse - -from lnbits.db import Database -from lnbits.helpers import template_renderer -from typing import Callable - -db = Database("ext_withdraw") +from .crud import db +from .views import withdraw_ext_generic +from .views_api import withdraw_ext_api +from .views_lnurl import withdraw_ext_lnurl withdraw_static_files = [ { "path": "/withdraw/static", - "app": StaticFiles(packages=[("lnbits", "extensions/withdraw/static")]), "name": "withdraw_static", } ] - -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.route_class = LNURLErrorResponseHandler +withdraw_ext.include_router(withdraw_ext_generic) +withdraw_ext.include_router(withdraw_ext_api) +withdraw_ext.include_router(withdraw_ext_lnurl) -def withdraw_renderer(): - return template_renderer(["lnbits/extensions/withdraw/templates"]) +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" + ) -from .lnurl import * # noqa: F401,F403 -from .views import * # noqa: F401,F403 -from .views_api import * # noqa: F401,F403 +__all__ = ["db", "withdraw_ext", "withdraw_start", "withdraw_static_files"] diff --git a/config.json b/config.json index c22d69c..e54edf5 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,80 @@ { - "name": "LNURLw", + "name": "Withdraw Links", "short_description": "Make LNURL withdraw links", - "tile": "/withdraw/static/image/lnurl-withdraw.png", - "contributors": ["arcbtc", "eillarra"] + "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" } diff --git a/crud.py b/crud.py index 83dd059..73966a5 100644 --- a/crud.py +++ b/crud.py @@ -1,100 +1,100 @@ 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 . import db -from .models import CreateWithdrawData, HashCheck, WithdrawLink +from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink + +db = Database("ext_withdraw") async def create_withdraw_link( data: CreateWithdrawData, wallet_id: str ) -> WithdrawLink: - link_id = urlsafe_short_hash()[:6] + link_id = urlsafe_short_hash()[:22] 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, - ), + 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, ) - link = await get_withdraw_link(link_id, 0) - assert link, "Newly created link couldn't be retrieved" + 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 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,) +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, ) - if not row: + if not link: return None - link = dict(**row) - link["number"] = num - - return WithdrawLink.parse_obj(link) + link.number = num + return 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,) +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, ) - 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,) + result = await db.execute( + f""" + SELECT COUNT(*) as total FROM withdraw.withdraw_link + WHERE wallet IN ({q}) + """ ) - return [WithdrawLink(**row) for row in rows] + result2 = result.mappings().first() + + return PaginatedWithdraws(data=links, total=int(result2.total)) async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: @@ -103,36 +103,25 @@ 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()) ] - await update_withdraw_link( - link.id, - usescsv=",".join(unique_links), - ) + link.usescsv = ",".join(unique_links) + await update_withdraw_link(link) 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()), - ) + link.used = link.used + 1 + link.open_time = int(datetime.now().timestamp()) + await update_withdraw_link(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 update_withdraw_link(link: WithdrawLink) -> WithdrawLink: + await db.update("withdraw.withdraw_link", link) + return link async def delete_withdraw_link(link_id: str) -> None: - await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) + await db.execute( + "DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id} + ) def chunks(lst, n): @@ -143,31 +132,45 @@ 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 (?, ?) + INSERT INTO withdraw.hash_check (id, lnurl_id) + VALUES (:id, :lnurl_id) """, - (the_hash, lnurl_id), + {"id": the_hash, "lnurl_id": lnurl_id}, ) - hashCheck = await get_hash_check(the_hash, lnurl_id) - return hashCheck + hash_check = await get_hash_check(the_hash, lnurl_id) + return hash_check 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,) + + hash_check = await db.fetchone( + """ + SELECT id as hash, lnurl_id as lnurl + FROM withdraw.hash_check WHERE id = :id + """, + {"id": the_hash}, + HashCheck, ) - rowlnurl = await db.fetchone( - "SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,) + 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, ) - if not rowlnurl: + if not hash_check_lnurl: await create_hash_check(the_hash, lnurl_id) return HashCheck(lnurl=True, hash=False) else: - if not rowid: + if not hash_check: 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 new file mode 100644 index 0000000..d918031 --- /dev/null +++ b/description.md @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..31efcff --- /dev/null +++ b/helpers.py @@ -0,0 +1,54 @@ +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 deleted file mode 100644 index 5ef521f..0000000 --- a/lnurl.py +++ /dev/null @@ -1,200 +0,0 @@ -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 index 8153bea..32602d7 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 95805ae..e27af8a 100644 --- a/migrations.py +++ b/migrations.py @@ -132,3 +132,16 @@ 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 new file mode 100644 index 0000000..1caf27c --- /dev/null +++ b/migrations_fork.py @@ -0,0 +1,44 @@ +""" +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 6b4ba0e..2f8ae48 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,7 @@ -import shortuuid +from datetime import datetime + 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 +from pydantic import BaseModel, Field class CreateWithdrawData(BaseModel): @@ -18,6 +15,13 @@ 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): @@ -34,46 +38,43 @@ class WithdrawLink(BaseModel): open_time: int = Query(0) used: int = Query(0) usescsv: str = Query(None) - number: int = Query(0) + number: int = Field(default=0, no_database=True) 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 = 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(str(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(str(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 new file mode 100644 index 0000000..8bfa4db --- /dev/null +++ b/package-lock.json @@ -0,0 +1,59 @@ +{ + "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 new file mode 100644 index 0000000..f63f688 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000..a091367 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,88 @@ +[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 new file mode 100644 index 0000000..c7b9a87 Binary files /dev/null and b/static/image/1.jpg differ diff --git a/static/image/1.png b/static/image/1.png new file mode 100644 index 0000000..751ed1a Binary files /dev/null and b/static/image/1.png differ diff --git a/static/image/2.png b/static/image/2.png new file mode 100644 index 0000000..1abfca3 Binary files /dev/null and b/static/image/2.png differ diff --git a/static/image/3.png b/static/image/3.png new file mode 100644 index 0000000..8d6c910 Binary files /dev/null and b/static/image/3.png differ diff --git a/static/image/4.png b/static/image/4.png new file mode 100644 index 0000000..565bfea Binary files /dev/null and b/static/image/4.png differ diff --git a/static/js/index.js b/static/js/index.js index ced7843..0b42b40 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,59 +1,63 @@ -/* 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) { +const 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({ +window.app = Vue.createApp({ el: '#vue', - mixins: [windowMixin], - data: function () { + mixins: [window.windowMixin], + data() { 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', label: 'Wait', field: 'wait_time' }, + { + name: 'uses', + align: 'right', + label: 'Uses', + field: 'uses' + }, { 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'} + { + name: 'max_withdrawable', + align: 'right', + label: 'Max (sat)', + field: 'max_withdrawable', + format: LNbits.utils.formatSat + } ], pagination: { - rowsPerPage: 10 + page: 1, + rowsPerPage: 10, + rowsNumber: 0 } }, nfcTagWriting: false, @@ -64,7 +68,8 @@ new Vue({ data: { is_unique: false, use_custom: false, - has_webhook: false + has_webhook: false, + enabled: true } }, simpleformDialog: { @@ -74,7 +79,8 @@ new Vue({ use_custom: false, title: 'Vouchers', min_withdrawable: 0, - wait_time: 1 + wait_time: 1, + enabled: true } }, qrCodeDialog: { @@ -84,62 +90,71 @@ new Vue({ } }, computed: { - sortedWithdrawLinks: function () { + sortedWithdrawLinks() { return this.withdrawLinks.sort(function (a, b) { return b.uses_left - a.uses_left }) } }, methods: { - getWithdrawLinks: function () { - var self = this + 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 + } LNbits.api .request( 'GET', - '/withdraw/api/v1/links?all_wallets=true', + `/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`, this.g.user.wallets[0].inkey ) - .then(function (response) { - self.withdrawLinks = response.data.map(function (obj) { - return mapWithdrawLink(obj) - }) + .then(response => { + this.withdrawLinks = response.data.data.map(mapWithdrawLink) + this.withdrawLinksTable.pagination.rowsNumber = response.data.total }) - .catch(function (error) { - clearInterval(self.checker) + .catch(error => { + clearInterval(this.checker) LNbits.utils.notifyApiError(error) }) }, - closeFormDialog: function () { + closeFormDialog() { this.formDialog.data = { is_unique: false, - use_custom: false + use_custom: false, + has_webhook: false, + enabled: true } }, - simplecloseFormDialog: function () { + simplecloseFormDialog() { this.simpleformDialog.data = { is_unique: false, - use_custom: false + use_custom: false, + enabled: true } }, - openQrCodeDialog: function (linkId) { - var link = _.findWhere(this.withdrawLinks, {id: linkId}) - + openQrCodeDialog(linkId) { + const 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: function (linkId) { - var link = _.findWhere(this.withdrawLinks, {id: linkId}) + openUpdateDialog(linkId) { + let 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: function () { - var wallet = _.findWhere(this.g.user.wallets, { + sendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) - var data = _.omit(this.formDialog.data, 'wallet') + const data = _.omit(this.formDialog.data, 'wallet') if (!data.use_custom) { data.custom_url = null @@ -156,17 +171,18 @@ new Vue({ 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, { + simplesendFormData() { + const wallet = _.findWhere(this.g.user.wallets, { id: this.simpleformDialog.data.wallet }) - var data = _.omit(this.simpleformDialog.data, 'wallet') + const data = _.omit(this.simpleformDialog.data, 'wallet') data.wait_time = 1 data.min_withdrawable = data.max_withdrawable @@ -187,29 +203,12 @@ new Vue({ 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 - } + updateWithdrawLink(wallet, data) { + // Remove webhook info if toggle is set to false + if (!data.has_webhook) { + data.webhook_url = null + data.webhook_headers = null + data.webhook_body = null } LNbits.api @@ -217,48 +216,47 @@ new Vue({ 'PUT', '/withdraw/api/v1/links/' + data.id, wallet.adminkey, - body + data ) - .then(function (response) { - self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { + .then(response => { + this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) { return obj.id === data.id }) - self.withdrawLinks.push(mapWithdrawLink(response.data)) - self.formDialog.show = false + this.withdrawLinks.push(mapWithdrawLink(response.data)) + this.formDialog.show = false + this.closeFormDialog() }) - .catch(function (error) { + .catch(error => { LNbits.utils.notifyApiError(error) }) }, - createWithdrawLink: function (wallet, data) { - var self = this - + createWithdrawLink(wallet, data) { 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 + .then(response => { + this.withdrawLinks.push(mapWithdrawLink(response.data)) + this.formDialog.show = false + this.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}) + deleteWithdrawLink(linkId) { + const link = _.findWhere(this.withdrawLinks, {id: linkId}) LNbits.utils .confirmDialog('Are you sure you want to delete this withdraw link?') - .onOk(function () { + .onOk(() => { LNbits.api .request( 'DELETE', '/withdraw/api/v1/links/' + linkId, - _.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey + _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey ) - .then(function (response) { - self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { + .then(() => { + this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) { return obj.id === linkId }) }) @@ -267,7 +265,7 @@ new Vue({ }) }) }, - writeNfcTag: async function (lnurl) { + async writeNfcTag(lnurl) { try { if (typeof NDEFReader == 'undefined') { throw { @@ -311,13 +309,10 @@ new Vue({ ) } }, - created: function () { + created() { if (this.g.user.wallets.length) { - var getWithdrawLinks = this.getWithdrawLinks - getWithdrawLinks() - this.checker = setInterval(function () { - getWithdrawLinks() - }, 300000) + this.getWithdrawLinks() + this.checker = setInterval(this.getWithdrawLinks, 300000) } } }) diff --git a/templates/withdraw/_api_docs.html b/templates/withdraw/_api_docs.html index ff88189..dbd7bf8 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: {{ user.wallets[0].inkey }}" + "X-Api-Key: " @@ -51,8 +51,8 @@
Curl example
curl -X GET {{ request.base_url - }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: + " @@ -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: {{ user.wallets[0].adminkey }}" + "X-Api-Key: " @@ -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: {{ - user.wallets[0].adminkey }}" + "Content-type: application/json" -H "X-Api-Key: + " @@ -147,8 +147,8 @@
Curl example
curl -X DELETE {{ request.base_url - }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: + " @@ -176,7 +176,7 @@ curl -X GET {{ request.base_url }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + "X-Api-Key: " diff --git a/templates/withdraw/csv.html b/templates/withdraw/csv.html deleted file mode 100644 index 6290290..0000000 --- a/templates/withdraw/csv.html +++ /dev/null @@ -1,12 +0,0 @@ -{% 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 3ef545c..812c95f 100644 --- a/templates/withdraw/display.html +++ b/templates/withdraw/display.html @@ -4,29 +4,32 @@
- {% if link.is_spent %} - Withdraw is spent. - {% endif %} - - - - - + Withdraw is spent. + Withdraw is spent. + Withdraw is disabled. + +
- Copy LNURL
@@ -52,15 +55,16 @@ {% endblock %} {% block scripts %} -{% endblock %} {% block page %} +%} {% block page %}
@@ -9,7 +7,11 @@ Quick vouchers - Advanced withdraw link(s) @@ -28,77 +30,72 @@ - {% raw %} - {% endraw %} @@ -129,7 +139,7 @@
- {{SITE_TITLE}} LNURL-withdraw extension + LNbits LNURL withdraw extension
@@ -211,7 +221,7 @@
+ + + + + + Enable / Disable + You can enable or disable these vouchers + + + + + + + + Enable / Disable + You can enable or disable these vouchers + + - - - {% raw %} - +

- ID: {{ qrCodeDialog.data.id }}
- Unique: {{ qrCodeDialog.data.is_unique }} + ID:
+ 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 }} + Max. withdrawable: + sat
+ Wait time: + seconds
+ Withdraws: + / +

- {% endraw %}
Copy LNURL - Copy sharable link - Write to NFC + Open sharable link + Print @@ -468,4 +504,6 @@
+{% endblock %}{% block scripts %} {{ window_vars(user) }} + {% endblock %} diff --git a/templates/withdraw/print_qr.html b/templates/withdraw/print_qr.html index df4ca7d..3b73b13 100644 --- a/templates/withdraw/print_qr.html +++ b/templates/withdraw/print_qr.html @@ -4,22 +4,21 @@
{% for page in link %} - - {% for threes in page %} - - {% for one in threes %} - +
+ {% for row in page %} +
+ {% for one in row %} +
+ +
{% endfor %} -
+ {% endfor %} -
-
- -
-
+
{% endfor %}
@@ -53,11 +52,9 @@ {% endblock %} {% block scripts %}