From 3d1fb02e5c076432335efb698d47e669ac011825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 25 Aug 2025 09:43:33 +0200 Subject: [PATCH 01/10] feat: introduce withdraw secrets - refactor lnurl endpoints - cleanup api endpoints (post|update) --- crud.py | 120 ++++++-------------------------- migrations.py | 4 ++ models.py | 111 +++++++++++++++++++++--------- views.py | 2 +- views_api.py | 107 ++++++----------------------- views_lnurl.py | 182 +++++++++++++++---------------------------------- 6 files changed, 181 insertions(+), 345 deletions(-) diff --git a/crud.py b/crud.py index 7d79ccd..a294112 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,6 @@ -from datetime import datetime - -import shortuuid from lnbits.db import Database -from lnbits.helpers import urlsafe_short_hash -from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink +from .models import CreateWithdrawData, PaginatedWithdraws, WithdrawLink, WithdrawSecret db = Database("ext_withdraw") @@ -12,56 +8,46 @@ async def create_withdraw_link( data: CreateWithdrawData, wallet_id: str ) -> 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, + wallet=data.wallet or wallet_id, 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, + is_static=data.is_static, webhook_url=data.webhook_url, webhook_headers=data.webhook_headers, webhook_body=data.webhook_body, custom_url=data.custom_url, - number=0, ) + secrets = [] + for _ in range(data.uses): + secrets.append( + WithdrawSecret( + withdraw_id=withdraw_link.id, + amount=withdraw_link.max_withdrawable, + ) + ) + withdraw_link.secrets.total = data.uses + withdraw_link.secrets.items = secrets 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( +async def get_withdraw_link(link_id: str) -> WithdrawLink | None: + return 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_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}, +async def get_withdraw_link_by_k1(k1: str) -> WithdrawLink | None: + return await db.fetchone( + "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE '%:k1%'", + {"k1": k1}, WithdrawLink, ) - if not link: - return None - - link.number = num - return link async def get_withdraw_links( @@ -96,22 +82,6 @@ async def get_withdraw_links( return PaginatedWithdraws(data=links, total=int(result2.total)) -async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: - unique_links = [ - x.strip() - for x in link.usescsv.split(",") - if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) - ] - link.usescsv = ",".join(unique_links) - await update_withdraw_link(link) - - -async def increment_withdraw_link(link: WithdrawLink) -> None: - link.used = link.used + 1 - link.open_time = int(datetime.now().timestamp()) + link.wait_time - await update_withdraw_link(link) - - async def update_withdraw_link(link: WithdrawLink) -> WithdrawLink: await db.update("withdraw.withdraw_link", link) return link @@ -121,55 +91,3 @@ async def delete_withdraw_link(link_id: str) -> None: await db.execute( "DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id} ) - - -def chunks(lst, n): - for i in range(0, len(lst), n): - yield lst[i : i + n] - - -async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: - await db.execute( - """ - INSERT INTO withdraw.hash_check (id, lnurl_id) - VALUES (:id, :lnurl_id) - """, - {"id": the_hash, "lnurl_id": lnurl_id}, - ) - 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: - - hash_check = await db.fetchone( - """ - SELECT id as hash, lnurl_id as lnurl - FROM withdraw.hash_check WHERE id = :id - """, - {"id": the_hash}, - HashCheck, - ) - 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 hash_check_lnurl: - await create_hash_check(the_hash, lnurl_id) - return HashCheck(lnurl=True, hash=False) - else: - 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/migrations.py b/migrations.py index 754a57f..4873fec 100644 --- a/migrations.py +++ b/migrations.py @@ -139,3 +139,7 @@ async def m007_add_created_at_timestamp(db): "ALTER TABLE withdraw.withdraw_link " f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}" ) + + +async def m008_add_secrets(db): + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN secrets TEXT;") diff --git a/models.py b/models.py index b476dce..268ec3f 100644 --- a/models.py +++ b/models.py @@ -1,51 +1,98 @@ -from datetime import datetime +import json +from datetime import datetime, timezone from fastapi import Query -from pydantic import BaseModel, Field +from lnbits.helpers import urlsafe_short_hash +from pydantic import BaseModel, Field, validator class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) max_withdrawable: int = Query(..., ge=1) - uses: int = Query(..., ge=1) + uses: int = Query(..., ge=1, le=250) wait_time: int = Query(..., ge=1) - is_unique: bool - webhook_url: str = Query(None) - webhook_headers: str = Query(None) - webhook_body: str = Query(None) - custom_url: str = Query(None) + is_static: bool = Query(True) + wallet: str | None = Query(None) + webhook_url: str | None = Query(None) + webhook_headers: str | None = Query(None) + webhook_body: str | None = Query(None) + custom_url: str | None = Query(None) + @validator("max_withdrawable") + def check_max_withdrawable(self, v, values): + if "min_withdrawable" in values and v < values["min_withdrawable"]: + raise ValueError("max_withdrawable must be at least min_withdrawable") + return v -class WithdrawLink(BaseModel): - id: str - wallet: str = Query(None) - title: str = Query(None) - min_withdrawable: int = Query(0) - max_withdrawable: int = Query(0) - uses: int = Query(0) - wait_time: int = Query(0) - is_unique: bool = Query(False) - unique_hash: str = Query(0) - k1: str = Query(None) - open_time: int = Query(0) - used: int = Query(0) - usescsv: str = Query(None) - number: int = 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) - created_at: datetime + @validator("webhook_body") + def check_webhook_body(self, v): + if v: + try: + json.loads(v) + except Exception as exc: + raise ValueError("webhook_body must be valid JSON") from exc + return v + + @validator("webhook_headers") + def check_headers_json(self, v): + if v: + try: + json.loads(v) + except Exception as exc: + raise ValueError("webhook_headers must be valid JSON") from exc + return v + + +class WithdrawSecret(BaseModel): + k1: str = Field(default_factory=urlsafe_short_hash) + withdraw_id: str + amount: int | None = None + used: bool = False + used_at: int | None = None + + +class WithdrawSecrets(BaseModel): + total: int = 0 + used: int = 0 + items: list[WithdrawSecret] = [] @property def is_spent(self) -> bool: - return self.used >= self.uses + return self.used >= self.total + @property + def next_secret(self) -> WithdrawSecret | None: + return next((item for item in self.items if not item.used), None) + + def get_secret(self, k1: str) -> WithdrawSecret | None: + return next((item for item in self.items if item.k1 == k1), None) + + def use_secret(self, k1: str) -> WithdrawSecret | None: + for item in self.items: + if item.k1 == k1 and not item.used: + item.used = True + item.used_at = int(datetime.now().timestamp()) + self.used += 1 + return item + return None -class HashCheck(BaseModel): - hash: bool - lnurl: bool + +class WithdrawLink(BaseModel): + id: str = Field(default_factory=lambda: urlsafe_short_hash()[:22]) + wallet: str + title: str + min_withdrawable: int + max_withdrawable: int + wait_time: int + is_static: bool + webhook_url: str | None = None + webhook_headers: str | None = None + webhook_body: str | None = None + custom_url: str | None = None + open_time: int = Field(default_factory=lambda: int(datetime.now().timestamp())) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + secrets: WithdrawSecrets = WithdrawSecrets() class PaginatedWithdraws(BaseModel): diff --git a/views.py b/views.py index ba48a5d..361324a 100644 --- a/views.py +++ b/views.py @@ -26,7 +26,7 @@ async def index(request: Request, user: User = Depends(check_user_exists)): @withdraw_ext_generic.get("/{link_id}", response_class=HTMLResponse) async def display(request: Request, link_id): - link = await get_withdraw_link(link_id, 0) + link = await get_withdraw_link(link_id) if not link: raise HTTPException( diff --git a/views_api.py b/views_api.py index f93d8f1..aa8785e 100644 --- a/views_api.py +++ b/views_api.py @@ -1,4 +1,3 @@ -import json from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException, Query @@ -9,17 +8,16 @@ from .crud import ( create_withdraw_link, delete_withdraw_link, - get_hash_check, get_withdraw_link, get_withdraw_links, update_withdraw_link, ) -from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink +from .models import CreateWithdrawData, PaginatedWithdraws, WithdrawLink withdraw_ext_api = APIRouter(prefix="/api/v1") -@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK) +@withdraw_ext_api.get("/links") async def api_links( key_info: WalletTypeInfo = Depends(require_invoice_key), all_wallets: bool = Query(False), @@ -35,11 +33,11 @@ async def api_links( return await get_withdraw_links(wallet_ids, limit, offset) -@withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK) +@withdraw_ext_api.get("/links/{link_id}") async def api_link_retrieve( link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key) ) -> WithdrawLink: - link = await get_withdraw_link(link_id, 0) + link = await get_withdraw_link(link_id) if not link: raise HTTPException( @@ -54,85 +52,34 @@ async def api_link_retrieve( @withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED) -@withdraw_ext_api.put("/links/{link_id}") -async def api_link_create_or_update( +async def api_link_create( data: CreateWithdrawData, - link_id: str | None = None, key_info: WalletTypeInfo = Depends(require_admin_key), ) -> WithdrawLink: - if data.uses > 250: - raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) + link = await create_withdraw_link(data, key_info.wallet.id) + return link - if data.min_withdrawable < 1: + +@withdraw_ext_api.put("/links/{link_id}") +async def api_link_update( + link_id: str, + data: CreateWithdrawData, + key_info: WalletTypeInfo = Depends(require_admin_key), +) -> WithdrawLink: + link = await get_withdraw_link(link_id) + if not link: raise HTTPException( - detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST + detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND ) - - if data.max_withdrawable < data.min_withdrawable: + if link.wallet != key_info.wallet.id: raise HTTPException( - detail="`max_withdrawable` needs to be at least `min_withdrawable`.", - status_code=HTTPStatus.BAD_REQUEST, + detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN ) - if data.webhook_body: - try: - json.loads(data.webhook_body) - except Exception as exc: - raise HTTPException( - detail="`webhook_body` can not parse JSON.", - status_code=HTTPStatus.BAD_REQUEST, - ) from exc - - if data.webhook_headers: - try: - json.loads(data.webhook_headers) - except Exception as exc: - raise HTTPException( - detail="`webhook_headers` can not parse JSON.", - status_code=HTTPStatus.BAD_REQUEST, - ) from exc - - if link_id: - link = await get_withdraw_link(link_id, 0) - if not link: - raise HTTPException( - detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND - ) - if link.wallet != key_info.wallet.id: - raise HTTPException( - detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN - ) - - if link.uses > data.uses: - if data.uses - link.used <= 0: - raise HTTPException( - detail="Cannot reduce uses below current used.", - status_code=HTTPStatus.BAD_REQUEST, - ) - numbers = link.usescsv.split(",") - link.usescsv = ",".join(numbers[: data.uses - link.used]) - - if link.uses < data.uses: - numbers = link.usescsv.split(",") - if numbers[-1] == "": - current_number = int(link.uses) - numbers[-1] = str(link.uses) - else: - current_number = int(numbers[-1]) - while len(numbers) < (data.uses - link.used): - current_number += 1 - numbers.append(str(current_number)) - link.usescsv = ",".join(numbers) - - for k, v in data.dict().items(): - if v is not None: - setattr(link, k, v) - - link = await update_withdraw_link(link) - else: - link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data) + for k, v in data.dict().items(): + setattr(link, k, v) - return link + return await update_withdraw_link(link) @withdraw_ext_api.delete("/links/{link_id}") @@ -153,13 +100,3 @@ async def api_link_delete( await delete_withdraw_link(link_id) return SimpleStatus(success=True, message="Withdraw link deleted.") - - -@withdraw_ext_api.get( - "/links/{the_hash}/{lnurl_id}", - status_code=HTTPStatus.OK, - dependencies=[Depends(require_invoice_key)], -) -async def api_hash_retrieve(the_hash, lnurl_id) -> HashCheck: - hash_check = await get_hash_check(the_hash, lnurl_id) - return hash_check diff --git a/views_lnurl.py b/views_lnurl.py index d62b21d..a487fc1 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -2,14 +2,14 @@ from datetime import datetime import httpx -import shortuuid from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse from lnbits.core.crud import update_payment from lnbits.core.models import Payment from lnbits.core.services import pay_invoice +from lnbits.exceptions import PaymentError from lnurl import ( CallbackUrl, + InvalidLnurl, LnurlErrorResponse, LnurlSuccessResponse, LnurlWithdrawResponse, @@ -18,140 +18,102 @@ from loguru import logger from pydantic import parse_obj_as -from .crud import ( - create_hash_check, - delete_hash_check, - get_withdraw_link_by_hash, - increment_withdraw_link, - remove_unique_withdraw_link, -) +from .crud import get_withdraw_link, get_withdraw_link_by_k1, update_withdraw_link from .models import WithdrawLink withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl") -@withdraw_ext_lnurl.get( - "/{unique_hash}", - response_class=JSONResponse, - name="withdraw.api_lnurl_response", -) +@withdraw_ext_lnurl.get("/{id_or_k1}") async def api_lnurl_response( - request: Request, unique_hash: str + request: Request, id_or_k1: str ) -> LnurlWithdrawResponse | LnurlErrorResponse: - link = await get_withdraw_link_by_hash(unique_hash) + link = await get_withdraw_link(id_or_k1) - if not link: - return LnurlErrorResponse(reason="Withdraw link does not exist.") - - if link.is_spent: - return LnurlErrorResponse(reason="Withdraw is spent.") - - if link.is_unique: - return LnurlErrorResponse(reason="This link requires an id_unique_hash.") - - url = str( - request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) - ) + # static links are identified by their id + if link: + if not link.is_static: + return LnurlErrorResponse( + reason="Withdraw link is not static. Only use 'id' for static links." + ) + secret = link.secrets.next_secret + if not secret: + return LnurlErrorResponse(reason="Withdraw is spent.") + + # non-static links are identified by their k1 + else: + link = await get_withdraw_link_by_k1(id_or_k1) + if not link: + return LnurlErrorResponse(reason="Withdraw link does not exist.") + secret = link.secrets.get_secret(id_or_k1) + if not secret: + return LnurlErrorResponse(reason="Invalid k1.") + if secret.used: + return LnurlErrorResponse(reason="Withdraw is spent.") + + url = request.url_for("withdraw.lnurl_callback") + try: + callback_url = parse_obj_as(CallbackUrl, str(url)) + except InvalidLnurl: + return LnurlErrorResponse(reason=f"Invalid callback URL. {url!s}") - callback_url = parse_obj_as(CallbackUrl, url) return LnurlWithdrawResponse( callback=callback_url, - k1=link.k1, + k1=secret.k1, minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000), maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000), defaultDescription=link.title, ) -@withdraw_ext_lnurl.get( - "/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."}, - }, -) +@withdraw_ext_lnurl.get("/cb", name="withdraw.lnurl_callback") async def api_lnurl_callback( - unique_hash: str, - k1: str, - pr: str, - id_unique_hash: str | None = None, + k1: str, pr: str ) -> LnurlErrorResponse | LnurlSuccessResponse: - link = await get_withdraw_link_by_hash(unique_hash) + link = await get_withdraw_link_by_k1(k1) if not link: - return LnurlErrorResponse(reason="withdraw link not found.") - - if link.is_spent: - return LnurlErrorResponse(reason="withdraw is spent.") - - if link.k1 != k1: - return LnurlErrorResponse(reason="k1 is wrong.") + return LnurlErrorResponse(reason="Invalid k1.") now = int(datetime.now().timestamp()) - if now < link.open_time: return LnurlErrorResponse( reason=f"wait link open_time {link.open_time - now} seconds." ) - if not id_unique_hash and link.is_unique: - return LnurlErrorResponse(reason="id_unique_hash is required for this link.") + secret = link.secrets.get_secret(k1) + if not secret: + return LnurlErrorResponse(reason="Invalid k1.") - if id_unique_hash: - if check_unique_link(link, id_unique_hash): - await remove_unique_withdraw_link(link, id_unique_hash) - else: - return LnurlErrorResponse(reason="id_unique_hash not found.") + if secret.used: + return LnurlErrorResponse(reason="Withdraw is spent.") - # Create a record with the id_unique_hash or unique_hash, if it already exists, - # raise an exception thus preventing the same LNURL from being processed twice. - try: - await create_hash_check(id_unique_hash or unique_hash, k1) - except Exception: - return LnurlErrorResponse(reason="LNURL already being processed.") + # IMPORTANT: update the link in the db before paying the invoice + # so that concurrent requests can't use the same secret + link.secrets.use_secret(k1) + await update_withdraw_link(link) try: payment = await pay_invoice( wallet_id=link.wallet, payment_request=pr, max_sat=link.max_withdrawable, - extra={"tag": "withdraw", "withdrawal_link_id": link.id}, + extra={"tag": "withdraw", "withdraw_id": link.id}, ) - await increment_withdraw_link(link) - # If the payment succeeds, delete the record with the unique_hash. - # TODO: we delete this now: "If it has unique_hash, do not delete to prevent - # the same LNURL from being processed twice." - await delete_hash_check(id_unique_hash or unique_hash) - - if link.webhook_url: - await dispatch_webhook(link, payment, pr) - return LnurlSuccessResponse() - except Exception as exc: - # If payment fails, delete the hash stored so another attempt can be made. - await delete_hash_check(id_unique_hash or unique_hash) - return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}") - - -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(",") - ) + except PaymentError as exc: + return LnurlErrorResponse(reason=f"Payment error: {exc.message}") + + if link.webhook_url: + await dispatch_webhook(link, payment, pr) + + return LnurlSuccessResponse() async def dispatch_webhook( link: WithdrawLink, payment: Payment, payment_request: str ) -> None: + if not link.webhook_url: + return async with httpx.AsyncClient() as client: try: r: httpx.Response = await client.post( @@ -178,35 +140,3 @@ async def dispatch_webhook( payment.extra["wh_success"] = False payment.extra["wh_message"] = str(exc) await update_payment(payment) - - -# FOR LNURLs WHICH ARE UNIQUE -@withdraw_ext_lnurl.get( - "/{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 -) -> LnurlWithdrawResponse | LnurlErrorResponse: - link = await get_withdraw_link_by_hash(unique_hash) - - if not link: - return LnurlErrorResponse(reason="Withdraw link does not exist.") - - if link.is_spent: - return LnurlErrorResponse(reason="Withdraw is spent.") - - if not check_unique_link(link, id_unique_hash): - return LnurlErrorResponse(reason="id_unique_hash not found for this link.") - - url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) - - callback_url = parse_obj_as(CallbackUrl, f"{url!s}?id_unique_hash={id_unique_hash}") - return LnurlWithdrawResponse( - callback=callback_url, - k1=link.k1, - minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000), - maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000), - defaultDescription=link.title, - ) From e8caea93057faed170fb01fe1ea834d43c498089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 25 Aug 2025 14:01:35 +0200 Subject: [PATCH 02/10] remove custom qr and helper --- helpers.py | 28 ------ templates/withdraw/print_qr_custom.html | 111 ------------------------ 2 files changed, 139 deletions(-) delete mode 100644 helpers.py delete mode 100644 templates/withdraw/print_qr_custom.html diff --git a/helpers.py b/helpers.py deleted file mode 100644 index 51eb948..0000000 --- a/helpers.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastapi import Request -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 diff --git a/templates/withdraw/print_qr_custom.html b/templates/withdraw/print_qr_custom.html deleted file mode 100644 index 8e34097..0000000 --- a/templates/withdraw/print_qr_custom.html +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "print.html" %} {% block page %} - -
-
- {% for page in link %} - - {% for one in page %} -
- ... - {{ amt }} sats -
- -
-
- {% endfor %} -
- {% endfor %} -
-
-{% endblock %} {% block styles %} - -{% endblock %} {% block scripts %} - -{% endblock %} From 091f11870414bf8cd7c28f41816c3259e95f04ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 25 Aug 2025 14:02:00 +0200 Subject: [PATCH 03/10] temp fix --- models.py | 4 +-- views.py | 105 ++++++++++++++++++++++-------------------------------- 2 files changed, 45 insertions(+), 64 deletions(-) diff --git a/models.py b/models.py index 268ec3f..3c959c8 100644 --- a/models.py +++ b/models.py @@ -9,7 +9,7 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) - max_withdrawable: int = Query(..., ge=1) + max_withdrawable: int = Query(...) uses: int = Query(..., ge=1, le=250) wait_time: int = Query(..., ge=1) is_static: bool = Query(True) @@ -20,7 +20,7 @@ class CreateWithdrawData(BaseModel): custom_url: str | None = Query(None) @validator("max_withdrawable") - def check_max_withdrawable(self, v, values): + def check_max_withdrawable(self, v: int, values) -> int: if "min_withdrawable" in values and v < values["min_withdrawable"]: raise ValueError("max_withdrawable must be at least min_withdrawable") return v diff --git a/views.py b/views.py index 361324a..05e625d 100644 --- a/views.py +++ b/views.py @@ -1,4 +1,5 @@ import io +from datetime import datetime, timezone from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException, Request @@ -6,9 +7,10 @@ from lnbits.core.models import User from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer +from lnurl import Lnurl +from pydantic import parse_obj_as -from .crud import chunks, get_withdraw_link -from .helpers import create_lnurl +from .crud import get_withdraw_link withdraw_ext_generic = APIRouter() @@ -33,12 +35,18 @@ async def display(request: Request, link_id): status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) + if link.open_time and link.open_time > datetime.now(timezone.utc).timestamp(): + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Withdraw link is not yet active.", + ) + return withdraw_renderer().TemplateResponse( "withdraw/display.html", { "request": request, - "spent": link.is_spent, - "unique_hash": link.unique_hash, + "spent": link.secrets.is_spent, + "unique_hash": link.secrets.next_secret, }, ) @@ -51,78 +59,51 @@ async def print_qr(request: Request, link_id): status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) - if link.uses == 0: - - return withdraw_renderer().TemplateResponse( - "withdraw/print_qr.html", - {"request": request, "link": link.json(), "unique": False}, - ) - links = [] - count = 0 - - for _ in link.usescsv.split(","): - linkk = await get_withdraw_link(link_id, count) - if not linkk: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." - ) - try: - lnurl = create_lnurl(linkk, request) - except ValueError as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=str(exc), - ) from exc - links.append(str(lnurl.bech32)) - count = count + 1 - page_link = list(chunks(links, 2)) - linked = list(chunks(page_link, 5)) - - if link.custom_url: - return withdraw_renderer().TemplateResponse( - "withdraw/print_qr_custom.html", - { - "request": request, - "link": page_link, - "unique": True, - "custom_url": link.custom_url, - "amt": link.max_withdrawable, - }, - ) + # if link.uses == 0: + + # return withdraw_renderer().TemplateResponse( + # "withdraw/print_qr.html", + # {"request": request, "link": link.json(), "unique": False}, + # ) + # links = [] + # count = 0 + + # for _ in link.usescsv.split(","): + # linkk = await get_withdraw_link(link_id, count) + # if not linkk: + # raise HTTPException( + # HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." + # ) + # try: + # lnurl = create_lnurl(linkk, request) + # except ValueError as exc: + # raise HTTPException( + # status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + # detail=str(exc), + # ) from exc + # links.append(str(lnurl.bech32)) + # count = count + 1 + # page_link = list(chunks(links, 2)) + # linked = list(chunks(page_link, 5)) return withdraw_renderer().TemplateResponse( - "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} + "withdraw/print_qr.html", {"request": request, "link": [], "unique": True} ) @withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse) -async def csv(request: Request, link_id): +async def csv(req: Request, link_id): link = await get_withdraw_link(link_id) if not link: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) - if link.uses == 0: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent." - ) - buffer = io.StringIO() count = 0 - for _ in link.usescsv.split(","): - linkk = await get_withdraw_link(link_id, count) - if not linkk: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." - ) - try: - lnurl = create_lnurl(linkk, request) - except ValueError as exc: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=str(exc), - ) from exc + for _ in link.secrets.items: + url = req.url_for("withdraw.lnurl_callback", id_or_secret=link_id) + lnurl = parse_obj_as(Lnurl, str(url)) buffer.write(f"{lnurl.bech32!s}\n") count += 1 From 21bce267f5db6810263f699a69e5d4be97de488e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 26 Aug 2025 12:07:59 +0200 Subject: [PATCH 04/10] fixup print and csv --- crud.py | 7 +-- migrations.py | 8 ++- models.py | 14 ++--- pyproject.toml | 1 + static/js/index.js | 27 +++++++--- templates/withdraw/index.html | 92 ++++++++++++++++++++------------ templates/withdraw/print_qr.html | 50 +++++++++++------ views.py | 75 +++++++++++++------------- views_api.py | 2 + views_lnurl.py | 90 ++++++++++++++++--------------- 10 files changed, 218 insertions(+), 148 deletions(-) diff --git a/crud.py b/crud.py index a294112..f898267 100644 --- a/crud.py +++ b/crud.py @@ -10,11 +10,12 @@ async def create_withdraw_link( ) -> WithdrawLink: withdraw_link = WithdrawLink( title=data.title, - wallet=data.wallet or wallet_id, + wallet=wallet_id, min_withdrawable=data.min_withdrawable, max_withdrawable=data.max_withdrawable, wait_time=data.wait_time, is_static=data.is_static, + is_public=data.is_public, webhook_url=data.webhook_url, webhook_headers=data.webhook_headers, webhook_body=data.webhook_body, @@ -44,8 +45,8 @@ async def get_withdraw_link(link_id: str) -> WithdrawLink | None: async def get_withdraw_link_by_k1(k1: str) -> WithdrawLink | None: return await db.fetchone( - "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE '%:k1%'", - {"k1": k1}, + "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE :k1", + {"k1": f"%{k1}%"}, WithdrawLink, ) diff --git a/migrations.py b/migrations.py index 4873fec..6a5b307 100644 --- a/migrations.py +++ b/migrations.py @@ -141,5 +141,11 @@ async def m007_add_created_at_timestamp(db): ) -async def m008_add_secrets(db): +async def m008_add_secrets_static_public(db): await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN secrets TEXT;") + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN is_static BOOLEAN;") + await db.execute("UPDATE withdraw.withdraw_link SET is_static = NOT is_unique;") + await db.execute( + "ALTER TABLE withdraw.withdraw_link ADD COLUMN is_public DEFAULT TRUE;" + ) + # await db.execute("DROP TABLE withdraw.hash_check;") diff --git a/models.py b/models.py index 3c959c8..3642b26 100644 --- a/models.py +++ b/models.py @@ -9,24 +9,24 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) - max_withdrawable: int = Query(...) + max_withdrawable: int = Query(..., ge=1) uses: int = Query(..., ge=1, le=250) wait_time: int = Query(..., ge=1) is_static: bool = Query(True) - wallet: str | None = Query(None) + is_public: bool = Query(True) webhook_url: str | None = Query(None) webhook_headers: str | None = Query(None) webhook_body: str | None = Query(None) custom_url: str | None = Query(None) @validator("max_withdrawable") - def check_max_withdrawable(self, v: int, values) -> int: + def check_max_withdrawable(cls, v: int, values) -> int: if "min_withdrawable" in values and v < values["min_withdrawable"]: raise ValueError("max_withdrawable must be at least min_withdrawable") return v @validator("webhook_body") - def check_webhook_body(self, v): + def check_webhook_body(cls, v): if v: try: json.loads(v) @@ -35,7 +35,7 @@ def check_webhook_body(self, v): return v @validator("webhook_headers") - def check_headers_json(self, v): + def check_headers_json(cls, v): if v: try: json.loads(v) @@ -47,9 +47,10 @@ def check_headers_json(self, v): class WithdrawSecret(BaseModel): k1: str = Field(default_factory=urlsafe_short_hash) withdraw_id: str - amount: int | None = None + amount: int used: bool = False used_at: int | None = None + payment_hash_melt: str | None = None class WithdrawSecrets(BaseModel): @@ -86,6 +87,7 @@ class WithdrawLink(BaseModel): max_withdrawable: int wait_time: int is_static: bool + is_public: bool webhook_url: str | None = None webhook_headers: str | None = None webhook_body: str | None = None diff --git a/pyproject.toml b/pyproject.toml index 8f281dd..3d53dba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.lint.pep8-naming] classmethod-decorators = [ "root_validator", + "validator", ] # Ignore unused imports in __init__.py files. diff --git a/static/js/index.js b/static/js/index.js index 9a8a9c1..0599289 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -68,7 +68,8 @@ window.app = Vue.createApp({ secondMultiplier: 'seconds', secondMultiplierOptions: ['seconds', 'minutes', 'hours'], data: { - is_unique: false, + is_static: false, + is_public: false, use_custom: false, has_webhook: false } @@ -76,7 +77,8 @@ window.app = Vue.createApp({ simpleformDialog: { show: false, data: { - is_unique: true, + is_static: true, + is_public: false, use_custom: false, title: 'Vouchers', min_withdrawable: 0, @@ -125,27 +127,38 @@ window.app = Vue.createApp({ }, closeFormDialog() { this.formDialog.data = { - is_unique: false, + is_static: false, + is_public: false, use_custom: false, has_webhook: false } }, simplecloseFormDialog() { this.simpleformDialog.data = { - is_unique: false, + is_static: false, + is_public: false, use_custom: false } }, + getNextSecret(secrets) { + return _.findWhere(secrets, {used: false}).k1 + }, openQrCodeDialog(linkId) { const link = _.findWhere(this.withdrawLinks, {id: linkId}) this.qrCodeDialog.data = _.clone(link) this.qrCodeDialog.show = true - this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${link.unique_hash}` + this.qrCodeDialog.progress = + link.secrets.used == 0 ? 0 : link.secrets.used / link.secrets.total + const id_or_k1 = link.is_static + ? link.id + : this.getNextSecret(link.secrets.items) + this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${id_or_k1}` }, 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.data.uses = 1 this.formDialog.show = true }, sendFormData() { @@ -185,7 +198,7 @@ window.app = Vue.createApp({ data.wait_time = 1 data.min_withdrawable = data.max_withdrawable data.title = 'vouchers' - data.is_unique = true + data.is_static = true if (!data.use_custom) { data.custom_url = null @@ -217,7 +230,7 @@ window.app = Vue.createApp({ data ) .then(response => { - this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) { + this.withdrawLinks = _.reject(this.withdrawLinks, obj => { return obj.id === data.id }) this.withdrawLinks.push(mapWithdrawLink(response.data)) diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html index 3b75e11..ac8d722 100644 --- a/templates/withdraw/index.html +++ b/templates/withdraw/index.html @@ -53,6 +53,7 @@
Withdraw links
type="text" label="Link title *" > +
+
+ +
+
+ +
+
- - label="Webhook custom data (optional)" hint="Custom data as JSON string, will get posted along with webhook 'body' field." > + + + + + + + Enable public/shareable URL. + This will create a shareable URL that anyone can use to + withdraw from this link. + + + @@ -267,18 +293,17 @@
+ Use static withdraw QR codes Use unique withdraw QR codes to reduce `assmilking` + >If enabled, the same withdraw QR code will be used for every + withdrawal with `wait_time` in between withdrawals. This makes + it prone for attackers to `assmilk` your withdraw link. - This is recommended if you are sharing the links on social - media or print QR codes. @@ -404,22 +429,22 @@
@update:lnurl="v => lnurl = v" prefix="lnurlw" > -

- ID:
- Unique: - - +

+ ID:
+ Static: + + (QR code will change after each withdrawal)
- Max. withdrawable: + Max. withdrawable: sat
- Wait time: + Wait time: seconds
- Withdraws: - / -
+ Withdraws: + / +
@@ -441,6 +466,7 @@

>Write to NFC
- {% for page in link %} - + - {% for threes in page %} - - {% for one in threes %} - + - {% endfor %} - {% endfor %}
+
- {% endfor %}
{% endblock %} {% block styles %} @@ -58,12 +51,39 @@ el: '#vue', data() { return { - theurl: location.protocol + '//' + location.host, - printDialog: { - show: true, - data: null + pages: [], + columns: 3, + rows: 4, + url: window.location.origin, + links: {{ links | tojson | safe }}, + } + }, + methods: { + preparePages() { + let tempPage = [] + let tempRow = [] + this.links.forEach((link, index) => { + tempRow.push(link) + if ((index + 1) % this.columns === 0) { + tempPage.push(tempRow) + tempRow = [] + } + if ((index + 1) % (this.columns * this.rows) === 0) { + this.pages.push(tempPage) + tempPage = [] + } + }) + if (tempRow.length > 0) { + tempPage.push(tempRow) + } + if (tempPage.length > 0) { + this.pages.push(tempPage) } } + }, + created() { + this.preparePages() + console.log(this.pages) } }) diff --git a/views.py b/views.py index 05e625d..85f8e5f 100644 --- a/views.py +++ b/views.py @@ -19,15 +19,17 @@ def withdraw_renderer(): return template_renderer(["withdraw/templates"]) -@withdraw_ext_generic.get("/", response_class=HTMLResponse) -async def index(request: Request, user: User = Depends(check_user_exists)): +@withdraw_ext_generic.get("/") +async def index( + request: Request, user: User = Depends(check_user_exists) +) -> HTMLResponse: return withdraw_renderer().TemplateResponse( "withdraw/index.html", {"request": request, "user": user.json()} ) -@withdraw_ext_generic.get("/{link_id}", response_class=HTMLResponse) -async def display(request: Request, link_id): +@withdraw_ext_generic.get("/{link_id}") +async def display(request: Request, link_id: str) -> HTMLResponse: link = await get_withdraw_link(link_id) if not link: @@ -35,6 +37,11 @@ async def display(request: Request, link_id): status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) + if link.is_public is False: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Withdraw link is not public." + ) + if link.open_time and link.open_time > datetime.now(timezone.utc).timestamp(): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, @@ -46,63 +53,53 @@ async def display(request: Request, link_id): { "request": request, "spent": link.secrets.is_spent, - "unique_hash": link.secrets.next_secret, + "secret": link.secrets.next_secret, }, ) -@withdraw_ext_generic.get("/print/{link_id}", response_class=HTMLResponse) -async def print_qr(request: Request, link_id): +@withdraw_ext_generic.get("/print/{link_id}") +async def print_qr( + request: Request, link_id: str, user: User = Depends(check_user_exists) +) -> HTMLResponse: link = await get_withdraw_link(link_id) if not link: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) - - # if link.uses == 0: - - # return withdraw_renderer().TemplateResponse( - # "withdraw/print_qr.html", - # {"request": request, "link": link.json(), "unique": False}, - # ) - # links = [] - # count = 0 - - # for _ in link.usescsv.split(","): - # linkk = await get_withdraw_link(link_id, count) - # if not linkk: - # raise HTTPException( - # HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." - # ) - # try: - # lnurl = create_lnurl(linkk, request) - # except ValueError as exc: - # raise HTTPException( - # status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - # detail=str(exc), - # ) from exc - # links.append(str(lnurl.bech32)) - # count = count + 1 - # page_link = list(chunks(links, 2)) - # linked = list(chunks(page_link, 5)) + if link.wallet not in user.wallet_ids: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="This is not your withdraw link." + ) + links = [] + for secret in link.secrets.items: + url = request.url_for("withdraw.lnurl", id_or_k1=secret.k1) + lnurl = parse_obj_as(Lnurl, str(url)) + links.append(str(lnurl.bech32)) return withdraw_renderer().TemplateResponse( - "withdraw/print_qr.html", {"request": request, "link": [], "unique": True} + "withdraw/print_qr.html", {"request": request, "links": links} ) -@withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse) -async def csv(req: Request, link_id): +@withdraw_ext_generic.get("/csv/{link_id}") +async def csv( + req: Request, link_id: str, user: User = Depends(check_user_exists) +) -> StreamingResponse: link = await get_withdraw_link(link_id) if not link: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) + if link.wallet not in user.wallet_ids: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="This is not your withdraw link." + ) buffer = io.StringIO() count = 0 - for _ in link.secrets.items: - url = req.url_for("withdraw.lnurl_callback", id_or_secret=link_id) + for secret in link.secrets.items: + url = req.url_for("withdraw.lnurl", id_or_k1=secret.k1) lnurl = parse_obj_as(Lnurl, str(url)) buffer.write(f"{lnurl.bech32!s}\n") count += 1 diff --git a/views_api.py b/views_api.py index aa8785e..b8e3a11 100644 --- a/views_api.py +++ b/views_api.py @@ -77,6 +77,8 @@ async def api_link_update( ) for k, v in data.dict().items(): + if k == "uses": + continue setattr(link, k, v) return await update_withdraw_link(link) diff --git a/views_lnurl.py b/views_lnurl.py index a487fc1..6c4abed 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -24,7 +24,46 @@ withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl") -@withdraw_ext_lnurl.get("/{id_or_k1}") +# note important that this endpoint is defined before the dynamic /{id_or_k1} endpoint +@withdraw_ext_lnurl.get("/cb", name="withdraw.lnurl_callback") +async def api_lnurl_callback( + k1: str, pr: str +) -> LnurlErrorResponse | LnurlSuccessResponse: + + link = await get_withdraw_link_by_k1(k1) + if not link: + return LnurlErrorResponse(reason="Invalid k1.") + + secret = link.secrets.get_secret(k1) + if not secret: + return LnurlErrorResponse(reason="Invalid k1.") + + if secret.used: + return LnurlErrorResponse(reason="Withdraw is spent.") + + # IMPORTANT: update the link in the db before paying the invoice + # so that concurrent requests can't use the same secret + link.open_time = int(datetime.now().timestamp()) + link.wait_time + link.secrets.use_secret(k1) + await update_withdraw_link(link) + + try: + payment = await pay_invoice( + wallet_id=link.wallet, + payment_request=pr, + max_sat=link.max_withdrawable, + extra={"tag": "withdraw", "withdraw_id": link.id}, + ) + except PaymentError as exc: + return LnurlErrorResponse(reason=f"Payment error: {exc.message}") + + if link.webhook_url: + await dispatch_webhook(link, payment, pr) + + return LnurlSuccessResponse() + + +@withdraw_ext_lnurl.get("/{id_or_k1}", name="withdraw.lnurl") async def api_lnurl_response( request: Request, id_or_k1: str ) -> LnurlWithdrawResponse | LnurlErrorResponse: @@ -51,6 +90,12 @@ async def api_lnurl_response( if secret.used: return LnurlErrorResponse(reason="Withdraw is spent.") + now = int(datetime.now().timestamp()) + if now < link.open_time: + return LnurlErrorResponse( + reason=f"wait link open_time {link.open_time - now} seconds." + ) + url = request.url_for("withdraw.lnurl_callback") try: callback_url = parse_obj_as(CallbackUrl, str(url)) @@ -66,49 +111,6 @@ async def api_lnurl_response( ) -@withdraw_ext_lnurl.get("/cb", name="withdraw.lnurl_callback") -async def api_lnurl_callback( - k1: str, pr: str -) -> LnurlErrorResponse | LnurlSuccessResponse: - - link = await get_withdraw_link_by_k1(k1) - if not link: - return LnurlErrorResponse(reason="Invalid k1.") - - now = int(datetime.now().timestamp()) - if now < link.open_time: - return LnurlErrorResponse( - reason=f"wait link open_time {link.open_time - now} seconds." - ) - - secret = link.secrets.get_secret(k1) - if not secret: - return LnurlErrorResponse(reason="Invalid k1.") - - if secret.used: - return LnurlErrorResponse(reason="Withdraw is spent.") - - # IMPORTANT: update the link in the db before paying the invoice - # so that concurrent requests can't use the same secret - link.secrets.use_secret(k1) - await update_withdraw_link(link) - - try: - payment = await pay_invoice( - wallet_id=link.wallet, - payment_request=pr, - max_sat=link.max_withdrawable, - extra={"tag": "withdraw", "withdraw_id": link.id}, - ) - except PaymentError as exc: - return LnurlErrorResponse(reason=f"Payment error: {exc.message}") - - if link.webhook_url: - await dispatch_webhook(link, payment, pr) - - return LnurlSuccessResponse() - - async def dispatch_webhook( link: WithdrawLink, payment: Payment, payment_request: str ) -> None: From 03babf1d9d75dd22b375f4df979bbd2648005297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 26 Aug 2025 12:54:23 +0200 Subject: [PATCH 05/10] use admin key --- static/js/index.js | 2 +- views_api.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 0599289..fcbfb22 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -114,7 +114,7 @@ window.app = Vue.createApp({ .request( 'GET', `/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`, - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(response => { this.withdrawLinks = response.data.data.map(mapWithdrawLink) diff --git a/views_api.py b/views_api.py index b8e3a11..82b9f9c 100644 --- a/views_api.py +++ b/views_api.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from lnbits.core.crud import get_user from lnbits.core.models import SimpleStatus, WalletTypeInfo -from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.decorators import require_admin_key from .crud import ( create_withdraw_link, @@ -19,7 +19,7 @@ @withdraw_ext_api.get("/links") async def api_links( - key_info: WalletTypeInfo = Depends(require_invoice_key), + key_info: WalletTypeInfo = Depends(require_admin_key), all_wallets: bool = Query(False), offset: int = Query(0), limit: int = Query(0), @@ -35,7 +35,7 @@ async def api_links( @withdraw_ext_api.get("/links/{link_id}") async def api_link_retrieve( - link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key) + link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key) ) -> WithdrawLink: link = await get_withdraw_link(link_id) From 0e103b3126a30f0e572352a15b80a65cac029528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 27 Aug 2025 08:27:34 +0200 Subject: [PATCH 06/10] style q-table --- static/js/index.js | 61 ++++++----------------------------- templates/withdraw/index.html | 49 +++++++++++----------------- 2 files changed, 28 insertions(+), 82 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index fcbfb22..ce2e98e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -29,22 +29,10 @@ window.app = Vue.createApp({ } }, { - name: 'wait_time', - align: 'right', - label: 'Wait', - field: 'wait_time' - }, - { - name: 'uses', - align: 'right', - label: 'Uses', - field: 'uses' - }, - { - name: 'uses_left', + name: 'progress', align: 'right', - label: 'Uses left', - field: 'uses_left' + label: 'Used / Total', + field: 'secrets' }, { name: 'max_withdrawable', @@ -54,6 +42,12 @@ window.app = Vue.createApp({ format: v => { return new Intl.NumberFormat(LOCALE).format(v) } + }, + { + name: 'wait_time', + align: 'right', + label: 'Wait', + field: 'wait_time' } ], pagination: { @@ -62,7 +56,6 @@ window.app = Vue.createApp({ rowsNumber: 0 } }, - nfcTagWriting: false, formDialog: { show: false, secondMultiplier: 'seconds', @@ -276,42 +269,6 @@ window.app = Vue.createApp({ }) }) }, - async writeNfcTag(lnurl) { - try { - if (typeof NDEFReader == 'undefined') { - throw { - toString: function () { - return 'NFC not supported on this device or browser.' - } - } - } - - const ndef = new NDEFReader() - - this.nfcTagWriting = true - this.$q.notify({ - message: 'Tap your NFC tag to write the LNURL-withdraw link to it.' - }) - - await ndef.write({ - records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}] - }) - - this.nfcTagWriting = false - this.$q.notify({ - type: 'positive', - message: 'NFC tag written successfully.' - }) - } catch (error) { - this.nfcTagWriting = false - this.$q.notify({ - type: 'negative', - message: error - ? error.toString() - : 'An unexpected error has occurred.' - }) - } - }, exportCSV() { LNbits.utils.exportCSV( this.withdrawLinksTable.columns, diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html index ac8d722..03c2d29 100644 --- a/templates/withdraw/index.html +++ b/templates/withdraw/index.html @@ -46,7 +46,6 @@
Withdraw links
:props="props" v-text="col.label" > - @@ -423,7 +427,7 @@
- + >

- Copy LNURL - Write to NFC Date: Wed, 27 Aug 2025 08:27:38 +0200 Subject: [PATCH 07/10] fix display --- templates/withdraw/display.html | 17 +---------------- views.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/templates/withdraw/display.html b/templates/withdraw/display.html index 4d06e11..927dc8e 100644 --- a/templates/withdraw/display.html +++ b/templates/withdraw/display.html @@ -11,22 +11,9 @@
-
- Copy LNURL - -
@@ -55,9 +42,7 @@
data() { return { spent: {{ 'true' if spent else 'false' }}, - url: `${window.location.origin}/withdraw/api/v1/lnurl/{{ unique_hash }}`, - lnurl: '', - nfcTagWriting: false + url: `${window.location.origin}/withdraw/api/v1/lnurl/{{ id_or_k1 }}`, } } }) diff --git a/views.py b/views.py index 85f8e5f..8879779 100644 --- a/views.py +++ b/views.py @@ -42,18 +42,29 @@ async def display(request: Request, link_id: str) -> HTMLResponse: status_code=HTTPStatus.FORBIDDEN, detail="Withdraw link is not public." ) - if link.open_time and link.open_time > datetime.now(timezone.utc).timestamp(): + if ( + not link.is_static + and link.open_time + and link.open_time > datetime.now(timezone.utc).timestamp() + ): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Withdraw link is not yet active.", ) + secret = link.secrets.next_secret + if not secret: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Withdraw link is out of withdraws.", + ) + return withdraw_renderer().TemplateResponse( "withdraw/display.html", { "request": request, - "spent": link.secrets.is_spent, - "secret": link.secrets.next_secret, + "spent": link.secrets.is_spent or secret.used, + "id_or_k1": link.id if link.is_static else secret.k1, }, ) From bdaa6d4c268cefc323daae7a0c41585367a8a295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 27 Aug 2025 08:37:57 +0200 Subject: [PATCH 08/10] fixup! --- templates/withdraw/print_qr.html | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/withdraw/print_qr.html b/templates/withdraw/print_qr.html index 8eae1b6..01985e9 100644 --- a/templates/withdraw/print_qr.html +++ b/templates/withdraw/print_qr.html @@ -83,7 +83,6 @@ }, created() { this.preparePages() - console.log(this.pages) } }) From b015705df80cfe58dd563964001c68e282dabe33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 27 Aug 2025 08:50:44 +0200 Subject: [PATCH 09/10] opentime only for static --- views_lnurl.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/views_lnurl.py b/views_lnurl.py index 6c4abed..95afb92 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -79,6 +79,12 @@ async def api_lnurl_response( if not secret: return LnurlErrorResponse(reason="Withdraw is spent.") + now = int(datetime.now().timestamp()) + if now < link.open_time: + return LnurlErrorResponse( + reason=f"wait link open_time {link.open_time - now} seconds." + ) + # non-static links are identified by their k1 else: link = await get_withdraw_link_by_k1(id_or_k1) @@ -90,12 +96,6 @@ async def api_lnurl_response( if secret.used: return LnurlErrorResponse(reason="Withdraw is spent.") - now = int(datetime.now().timestamp()) - if now < link.open_time: - return LnurlErrorResponse( - reason=f"wait link open_time {link.open_time - now} seconds." - ) - url = request.url_for("withdraw.lnurl_callback") try: callback_url = parse_obj_as(CallbackUrl, str(url)) From 81b0f7a1527d5e489d149eb6e66e9ce505578f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Wed, 27 Aug 2025 08:52:51 +0200 Subject: [PATCH 10/10] remove ignores --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3d53dba..687afd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,9 +53,7 @@ line-length = 88 # 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"] +ignore = [] # Allow autofix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"]