diff --git a/crud.py b/crud.py index 7d79ccd..f898267 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,47 @@ 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=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, + is_public=data.is_public, 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": f"%{k1}%"}, WithdrawLink, ) - if not link: - return None - - link.number = num - return link async def get_withdraw_links( @@ -96,22 +83,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 +92,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/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/migrations.py b/migrations.py index 754a57f..6a5b307 100644 --- a/migrations.py +++ b/migrations.py @@ -139,3 +139,13 @@ 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_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 b476dce..3642b26 100644 --- a/models.py +++ b/models.py @@ -1,51 +1,100 @@ -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) + 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(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 -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(cls, 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(cls, 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 + used: bool = False + used_at: int | None = None + payment_hash_melt: str | 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 + is_public: 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/pyproject.toml b/pyproject.toml index 8f281dd..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"] @@ -68,6 +66,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..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', + name: 'progress', align: 'right', - label: 'Uses', - field: 'uses' - }, - { - name: 'uses_left', - 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,13 +56,13 @@ window.app = Vue.createApp({ rowsNumber: 0 } }, - nfcTagWriting: false, formDialog: { show: false, secondMultiplier: 'seconds', secondMultiplierOptions: ['seconds', 'minutes', 'hours'], data: { - is_unique: false, + is_static: false, + is_public: false, use_custom: false, has_webhook: false } @@ -76,7 +70,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, @@ -112,7 +107,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) @@ -125,27 +120,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 +191,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 +223,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)) @@ -263,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/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/templates/withdraw/index.html b/templates/withdraw/index.html index 3b75e11..03c2d29 100644 --- a/templates/withdraw/index.html +++ b/templates/withdraw/index.html @@ -46,13 +46,13 @@
Withdraw links
:props="props" v-text="col.label" > - @@ -158,23 +163,31 @@
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 +297,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. @@ -398,49 +427,35 @@
- + -

- ID:
- Unique: - - +

+ ID:
+ Static: + + (QR code will change after each withdrawal)
- Max. withdrawable: + Max. withdrawable: sat
- Wait time: + Wait time: seconds
- Withdraws: - / -
+ Withdraws: + / +

Copy LNURL - Write to NFC -
- {% for page in link %} - + - {% for threes in page %} - - {% for one in threes %} - + - {% endfor %} - {% endfor %}
+
- {% endfor %}
{% endblock %} {% block styles %} @@ -58,12 +51,38 @@ 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() } }) 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 %} diff --git a/views.py b/views.py index ba48a5d..8879779 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() @@ -17,112 +19,99 @@ 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): - link = await get_withdraw_link(link_id, 0) +@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: raise HTTPException( 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 ( + 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.is_spent, - "unique_hash": link.unique_hash, + "spent": link.secrets.is_spent or secret.used, + "id_or_k1": link.id if link.is_static else secret.k1, }, ) -@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}, + if link.wallet not in user.wallet_ids: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="This is not your withdraw link." ) 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 + 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)) - count = count + 1 - page_link = list(chunks(links, 2)) - linked = list(chunks(page_link, 5)) - - if link.custom_url: - return withdraw_renderer().TemplateResponse( - "withdraw/print_qr_custom.html", - { - "request": request, - "link": page_link, - "unique": True, - "custom_url": link.custom_url, - "amt": link.max_withdrawable, - }, - ) return withdraw_renderer().TemplateResponse( - "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} + "withdraw/print_qr.html", {"request": request, "links": links} ) -@withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse) -async def csv(request: 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.uses == 0: + if link.wallet not in user.wallet_ids: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent." + status_code=HTTPStatus.FORBIDDEN, detail="This is not your withdraw link." ) 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 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 f93d8f1..82b9f9c 100644 --- a/views_api.py +++ b/views_api.py @@ -1,27 +1,25 @@ -import json from http import HTTPStatus 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, 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), + key_info: WalletTypeInfo = Depends(require_admin_key), all_wallets: bool = Query(False), offset: int = Query(0), limit: int = Query(0), @@ -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) + link_id: str, key_info: WalletTypeInfo = Depends(require_admin_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,36 @@ 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(): + if k == "uses": + continue + setattr(link, k, v) - return link + return await update_withdraw_link(link) @withdraw_ext_api.delete("/links/{link_id}") @@ -153,13 +102,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..95afb92 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,104 @@ 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", -) -async def api_lnurl_response( - request: Request, 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 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) - ) - - callback_url = parse_obj_as(CallbackUrl, url) - return LnurlWithdrawResponse( - callback=callback_url, - k1=link.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."}, - }, -) +# 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( - 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.") + return LnurlErrorResponse(reason="Invalid k1.") - if link.k1 != k1: - return LnurlErrorResponse(reason="k1 is wrong.") + secret = link.secrets.get_secret(k1) + if not secret: + 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.") - - 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.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", "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() + + +@withdraw_ext_lnurl.get("/{id_or_k1}", name="withdraw.lnurl") +async def api_lnurl_response( + request: Request, id_or_k1: str +) -> LnurlWithdrawResponse | LnurlErrorResponse: + link = await get_withdraw_link(id_or_k1) + + # 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.") + + 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) + 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}") + + return LnurlWithdrawResponse( + callback=callback_url, + k1=secret.k1, + minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000), + maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000), + defaultDescription=link.title, ) 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 +142,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, - )