feat: add lud17 support (#60)

This commit is contained in:
dni ⚡ 2025-08-25 12:25:20 +02:00 committed by GitHub
commit 10a4caff7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 92 additions and 155 deletions

View file

@ -4,7 +4,7 @@ import shortuuid
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateWithdrawData, HashCheck, WithdrawLink from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
db = Database("ext_withdraw") db = Database("ext_withdraw")
@ -66,7 +66,7 @@ async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> WithdrawLink | N
async def get_withdraw_links( async def get_withdraw_links(
wallet_ids: list[str], limit: int, offset: int wallet_ids: list[str], limit: int, offset: int
) -> tuple[list[WithdrawLink], int]: ) -> PaginatedWithdraws:
q = ",".join([f"'{w}'" for w in wallet_ids]) q = ",".join([f"'{w}'" for w in wallet_ids])
query_str = f""" query_str = f"""
@ -85,16 +85,15 @@ async def get_withdraw_links(
query_params, query_params,
WithdrawLink, WithdrawLink,
) )
result = await db.execute( result = await db.execute(
f""" f"""
SELECT COUNT(*) as total FROM withdraw.withdraw_link SELECT COUNT(*) as total FROM withdraw.withdraw_link
WHERE wallet IN ({q}) WHERE wallet IN ({q})
""" """
) )
total = result.mappings().first() result2 = result.mappings().first()
return links, total.total return PaginatedWithdraws(data=links, total=int(result2.total))
async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:

View file

@ -46,3 +46,8 @@ class WithdrawLink(BaseModel):
class HashCheck(BaseModel): class HashCheck(BaseModel):
hash: bool hash: bool
lnurl: bool lnurl: bool
class PaginatedWithdraws(BaseModel):
data: list[WithdrawLink]
total: int

View file

@ -1,17 +1,6 @@
const locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
const mapWithdrawLink = function (obj) { const mapWithdrawLink = function (obj) {
obj._data = _.clone(obj) obj._data = _.clone(obj)
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.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) obj._data.use_custom = Boolean(obj.custom_url)
return obj return obj
} }
@ -25,6 +14,7 @@ window.app = Vue.createApp({
return { return {
checker: null, checker: null,
withdrawLinks: [], withdrawLinks: [],
lnurl: '',
withdrawLinksTable: { withdrawLinksTable: {
columns: [ columns: [
{name: 'title', align: 'left', label: 'Title', field: 'title'}, {name: 'title', align: 'left', label: 'Title', field: 'title'},
@ -34,7 +24,7 @@ window.app = Vue.createApp({
label: 'Created At', label: 'Created At',
field: 'created_at', field: 'created_at',
sortable: true, sortable: true,
format: function (val, row) { format: function (val) {
return new Date(val).toLocaleString() return new Date(val).toLocaleString()
} }
}, },
@ -47,7 +37,7 @@ window.app = Vue.createApp({
{ {
name: 'uses', name: 'uses',
align: 'right', align: 'right',
label: 'Created', label: 'Uses',
field: 'uses' field: 'uses'
}, },
{ {
@ -56,8 +46,15 @@ window.app = Vue.createApp({
label: 'Uses left', label: 'Uses left',
field: '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: v => {
return new Intl.NumberFormat(LOCALE).format(v)
}
}
], ],
pagination: { pagination: {
page: 1, page: 1,
@ -141,11 +138,9 @@ window.app = Vue.createApp({
}, },
openQrCodeDialog(linkId) { openQrCodeDialog(linkId) {
const link = _.findWhere(this.withdrawLinks, {id: linkId}) const link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link) this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${link.unique_hash}`
}, },
openUpdateDialog(linkId) { openUpdateDialog(linkId) {
let link = _.findWhere(this.withdrawLinks, {id: linkId}) let link = _.findWhere(this.withdrawLinks, {id: linkId})
@ -258,7 +253,7 @@ window.app = Vue.createApp({
'/withdraw/api/v1/links/' + linkId, '/withdraw/api/v1/links/' + linkId,
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
) )
.then(response => { .then(() => {
this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) { this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
return obj.id === linkId return obj.id === linkId
}) })

View file

@ -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 %}
<script>
window.app = Vue.createApp({
el: '#vue',
data: function () {
return {}
}
})
</script>
{% endblock %}

View file

@ -4,24 +4,26 @@
<q-card class="q-pa-lg"> <q-card class="q-pa-lg">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div class="text-center"> <div class="text-center">
{% if link.is_spent %} <q-badge v-if="spent" color="red" class="q-mb-md"
<q-badge color="red" class="q-mb-md">Withdraw is spent.</q-badge> >Withdraw is spent.</q-badge
{% endif %} >
<a class="text-secondary" href="lightning:{{ lnurl }}"> <a v-else class="text-secondary" :href="link">
<lnbits-qrcode <lnbits-qrcode-lnurl
:value="this.here + '/?lightning={{lnurl }}'" prefix="lnurlw"
></lnbits-qrcode> :url="url"
@update:lnurl="v => lnurl = v"
></lnbits-qrcode-lnurl>
</a> </a>
</div> </div>
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')" <q-btn outline color="grey" @click="copyText(lnurl)"
>Copy LNURL</q-btn >Copy LNURL</q-btn
> >
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="nfc" icon="nfc"
@click="writeNfcTag(' {{ lnurl }} ')" @click="writeNfcTag(lnurl)"
:disable="nfcTagWriting" :disable="nfcTagWriting"
></q-btn> ></q-btn>
</div> </div>
@ -52,7 +54,9 @@
mixins: [window.windowMixin], mixins: [window.windowMixin],
data() { data() {
return { return {
here: location.protocol + '//' + location.host, spent: {{ 'true' if spent else 'false' }},
url: `${window.location.origin}/withdraw/api/v1/lnurl/{{ unique_hash }}`,
lnurl: '',
nfcTagWriting: false nfcTagWriting: false
} }
} }

View file

@ -57,41 +57,27 @@
dense dense
size="xs" size="xs"
icon="launch" icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a" type="a"
:href="props.row.withdraw_url" :href="'/withdraw/' + props.row.id"
target="_blank" target="_blank"
> >
<q-tooltip> shareable link </q-tooltip></q-btn <q-tooltip>Shareable link</q-tooltip></q-btn
>
<q-btn
unelevated
dense
size="xs"
icon="web_asset"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/withdraw/img/' + props.row.id"
target="_blank"
><q-tooltip> embeddable image </q-tooltip></q-btn
> >
<q-btn <q-btn
unelevated unelevated
dense dense
size="xs" size="xs"
icon="reorder" icon="reorder"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a" type="a"
:href="'/withdraw/csv/' + props.row.id" :href="'/withdraw/csv/' + props.row.id"
target="_blank" target="_blank"
><q-tooltip> csv list </q-tooltip></q-btn ><q-tooltip>CSV download</q-tooltip></q-btn
> >
<q-btn <q-btn
unelevated unelevated
dense dense
size="xs" size="xs"
icon="visibility" icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)" @click="openQrCodeDialog(props.row.id)"
><q-tooltip>view LNURL</q-tooltip></q-btn ><q-tooltip>view LNURL</q-tooltip></q-btn
> >
@ -139,7 +125,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none"> <h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} LNURL-withdraw extension LNbits LNURL withdraw extension
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
@ -413,9 +399,11 @@
<q-dialog v-model="qrCodeDialog.show" position="top"> <q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card"> <q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<lnbits-qrcode <lnbits-qrcode-lnurl
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :url="activeUrl"
></lnbits-qrcode> @update:lnurl="v => lnurl = v"
prefix="lnurlw"
></lnbits-qrcode-lnurl>
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br /> <strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
<strong>Unique:</strong> <strong>Unique:</strong>
@ -440,31 +428,32 @@
<q-btn <q-btn
outline outline
color="grey" color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')" @click="copyText(lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm" class="q-ml-sm"
>Copy LNURL</q-btn >Copy LNURL</q-btn
> >
<q-btn
outline
color="grey"
icon="link"
@click="copyText(qrCodeDialog.data.withdraw_url, 'Link copied to clipboard!')"
><q-tooltip>Copy sharable link</q-tooltip>
</q-btn>
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="nfc" icon="nfc"
@click="writeNfcTag(qrCodeDialog.data.lnurl)" @click="writeNfcTag(lnurl)"
:disable="nfcTagWriting" :disable="nfcTagWriting"
><q-tooltip>Write to NFC</q-tooltip></q-btn ><q-tooltip>Write to NFC</q-tooltip></q-btn
> >
<q-btn
outline
color="grey"
icon="link"
:href="'/withdraw/' + qrCodeDialog.data.id"
target="_blank"
><q-tooltip>Open sharable link</q-tooltip>
</q-btn>
<q-btn <q-btn
outline outline
color="grey" color="grey"
icon="print" icon="print"
type="a" type="a"
:href="qrCodeDialog.data.print_url" :href="'/withdraw/print/' + qrCodeDialog.data.id"
target="_blank" target="_blank"
><q-tooltip>Print</q-tooltip></q-btn ><q-tooltip>Print</q-tooltip></q-btn
> >

View file

@ -1,7 +1,8 @@
import io
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, StreamingResponse
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
@ -32,21 +33,12 @@ async def display(request: Request, link_id):
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/display.html", "withdraw/display.html",
{ {
"request": request, "request": request,
"link": link.json(), "spent": link.is_spent,
"lnurl": lnurl, "unique_hash": link.unique_hash,
"unique": True,
}, },
) )
@ -58,8 +50,6 @@ async def print_qr(request: Request, link_id):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
) )
# response.status_code = HTTPStatus.NOT_FOUND
# return "Withdraw link does not exist."
if link.uses == 0: if link.uses == 0:
@ -83,7 +73,7 @@ async def print_qr(request: Request, link_id):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc), detail=str(exc),
) from exc ) from exc
links.append(str(lnurl)) links.append(str(lnurl.bech32))
count = count + 1 count = count + 1
page_link = list(chunks(links, 2)) page_link = list(chunks(links, 2))
linked = list(chunks(page_link, 5)) linked = list(chunks(page_link, 5))
@ -114,14 +104,12 @@ async def csv(request: Request, link_id):
) )
if link.uses == 0: if link.uses == 0:
raise HTTPException(
return withdraw_renderer().TemplateResponse( status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent."
"withdraw/csv.html",
{"request": request, "link": link.json(), "unique": False},
) )
links = []
count = 0
buffer = io.StringIO()
count = 0
for _ in link.usescsv.split(","): for _ in link.usescsv.split(","):
linkk = await get_withdraw_link(link_id, count) linkk = await get_withdraw_link(link_id, count)
if not linkk: if not linkk:
@ -135,11 +123,16 @@ async def csv(request: Request, link_id):
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc), detail=str(exc),
) from exc ) from exc
links.append(str(lnurl)) buffer.write(f"{lnurl.bech32!s}\n")
count = count + 1 count += 1
page_link = list(chunks(links, 2))
linked = list(chunks(page_link, 5))
return withdraw_renderer().TemplateResponse( # Move buffer cursor to the beginning
"withdraw/csv.html", {"request": request, "link": linked, "unique": True} buffer.seek(0)
return StreamingResponse(
buffer,
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=withdraw-links-{link_id}.csv"
},
) )

View file

@ -1,9 +1,9 @@
import json import json
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import SimpleStatus, WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import require_admin_key, require_invoice_key
from .crud import ( from .crud import (
@ -14,51 +14,31 @@ from .crud import (
get_withdraw_links, get_withdraw_links,
update_withdraw_link, update_withdraw_link,
) )
from .helpers import create_lnurl from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
from .models import CreateWithdrawData, HashCheck
withdraw_ext_api = APIRouter(prefix="/api/v1") withdraw_ext_api = APIRouter(prefix="/api/v1")
@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/links", status_code=HTTPStatus.OK)
async def api_links( async def api_links(
request: Request,
key_info: WalletTypeInfo = Depends(require_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
offset: int = Query(0), offset: int = Query(0),
limit: int = Query(0), limit: int = Query(0),
): ) -> PaginatedWithdraws:
wallet_ids = [key_info.wallet.id] wallet_ids = [key_info.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(key_info.wallet.user) user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else [] wallet_ids = user.wallet_ids if user else []
links, total = await get_withdraw_links(wallet_ids, limit, offset) return await get_withdraw_links(wallet_ids, limit, offset)
data = []
for link in links:
try:
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
data.append({**link.dict(), **{"lnurl": lnurl}})
return {
"data": data,
"total": total,
}
@withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_retrieve( async def api_link_retrieve(
link_id: str, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
request: Request, ) -> WithdrawLink:
key_info: WalletTypeInfo = Depends(require_invoice_key),
):
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
if not link: if not link:
@ -70,24 +50,16 @@ async def api_link_retrieve(
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
try: return link
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
return {**link.dict(), **{"lnurl": lnurl}}
@withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED) @withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED)
@withdraw_ext_api.put("/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.put("/links/{link_id}")
async def api_link_create_or_update( async def api_link_create_or_update(
request: Request,
data: CreateWithdrawData, data: CreateWithdrawData,
link_id: str | None = None, link_id: str | None = None,
key_info: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
): ) -> WithdrawLink:
if data.uses > 250: if data.uses > 250:
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
@ -160,21 +132,13 @@ async def api_link_create_or_update(
else: else:
link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data) link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data)
try: return link
lnurl = create_lnurl(link, request)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc),
) from exc
return {**link.dict(), **{"lnurl": lnurl}}
@withdraw_ext_api.delete("/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.delete("/links/{link_id}")
async def api_link_delete( async def api_link_delete(
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key) link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
): ) -> SimpleStatus:
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
if not link: if not link:
@ -188,7 +152,7 @@ async def api_link_delete(
) )
await delete_withdraw_link(link_id) await delete_withdraw_link(link_id)
return {"success": True} return SimpleStatus(success=True, message="Withdraw link deleted.")
@withdraw_ext_api.get( @withdraw_ext_api.get(