Compare commits

..

10 commits

Author SHA1 Message Date
dni ⚡
2e52400f52
fix: enforce check minimum (#72)
Some checks failed
lint / lint (push) Has been cancelled
2026-03-31 09:54:41 +01:00
Tiago Vasconcelos
74852e3494
feat: add disable option for LNURLw (#70) 2026-03-17 21:41:17 +00:00
dni ⚡
ab96594f70
chore: update to 1.2.2
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-12-27 09:48:17 +01:00
PatMulligan
8a20df70fe
FIX: generate LNURL server-side for unique voucher links (#68)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 09:45:57 +01:00
dni ⚡
68ff753cfd
fix: format function for table column (#67) 2025-12-15 07:41:36 +01:00
dni ⚡
eb7f7fda47
chore: update to version 1.2.1 (#66)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-10-06 18:47:56 +02:00
dni ⚡
720aa694c1
fix: revert withdraw to using bech32 lnurl field (#65) 2025-10-06 18:44:49 +02:00
Arc
d0689b7859
fix: timing logic for time between withdraws (#63) 2025-09-15 10:00:40 +02:00
Tiago Vasconcelos
8efacf2d4c
fix: print qr code (#62)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-09-12 14:26:18 +01:00
dni ⚡
10a4caff7e
feat: add lud17 support (#60) 2025-08-25 12:25:20 +02:00
14 changed files with 229 additions and 150 deletions

View file

@ -2,7 +2,7 @@
"name": "Withdraw Links", "name": "Withdraw Links",
"short_description": "Make LNURL withdraw links", "short_description": "Make LNURL withdraw links",
"tile": "/withdraw/static/image/lnurl-withdraw.png", "tile": "/withdraw/static/image/lnurl-withdraw.png",
"version": "1.1.0", "version": "1.2.2",
"min_lnbits_version": "1.3.0", "min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {

11
crud.py
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:
@ -109,7 +108,7 @@ async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> N
async def increment_withdraw_link(link: WithdrawLink) -> None: async def increment_withdraw_link(link: WithdrawLink) -> None:
link.used = link.used + 1 link.used = link.used + 1
link.open_time = int(datetime.now().timestamp()) + link.wait_time link.open_time = int(datetime.now().timestamp())
await update_withdraw_link(link) await update_withdraw_link(link)

View file

@ -139,3 +139,9 @@ async def m007_add_created_at_timestamp(db):
"ALTER TABLE withdraw.withdraw_link " "ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}" f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
) )
async def m008_add_enabled_column(db):
await db.execute(
"ALTER TABLE withdraw.withdraw_link ADD COLUMN enabled BOOLEAN DEFAULT true;"
)

View file

@ -15,6 +15,7 @@ class CreateWithdrawData(BaseModel):
webhook_headers: str = Query(None) webhook_headers: str = Query(None)
webhook_body: str = Query(None) webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
enabled: bool = Query(True)
class WithdrawLink(BaseModel): class WithdrawLink(BaseModel):
@ -37,6 +38,22 @@ class WithdrawLink(BaseModel):
webhook_body: str = Query(None) webhook_body: str = Query(None)
custom_url: str = Query(None) custom_url: str = Query(None)
created_at: datetime created_at: datetime
enabled: bool = Query(True)
lnurl: str | None = Field(
default=None,
no_database=True,
deprecated=True,
description=(
"Deprecated: Instead of using this bech32 encoded string, dynamically "
"generate your own static link (lud17/bech32) on the client side. "
"Example: lnurlw://${window.location.hostname}/lnurlw/${id}"
),
)
lnurl_url: str | None = Field(
default=None,
no_database=True,
description="The raw LNURL callback URL (use for QR code generation)",
)
@property @property
def is_spent(self) -> bool: def is_spent(self) -> bool:
@ -46,3 +63,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

@ -7,6 +7,9 @@ authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" } urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/bitcoinswitch_extension" }
dependencies = [ "lnbits>1" ] dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
[tool.uv] [tool.uv]
dev-dependencies = [ dev-dependencies = [
"black", "black",

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,13 @@ 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: LNbits.utils.formatSat
}
], ],
pagination: { pagination: {
page: 1, page: 1,
@ -73,7 +68,8 @@ window.app = Vue.createApp({
data: { data: {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false has_webhook: false,
enabled: true
} }
}, },
simpleformDialog: { simpleformDialog: {
@ -83,7 +79,8 @@ window.app = Vue.createApp({
use_custom: false, use_custom: false,
title: 'Vouchers', title: 'Vouchers',
min_withdrawable: 0, min_withdrawable: 0,
wait_time: 1 wait_time: 1,
enabled: true
} }
}, },
qrCodeDialog: { qrCodeDialog: {
@ -130,22 +127,22 @@ window.app = Vue.createApp({
this.formDialog.data = { this.formDialog.data = {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false has_webhook: false,
enabled: true
} }
}, },
simplecloseFormDialog() { simplecloseFormDialog() {
this.simpleformDialog.data = { this.simpleformDialog.data = {
is_unique: false, is_unique: false,
use_custom: false use_custom: false,
enabled: true
} }
}, },
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 = link.lnurl_url
}, },
openUpdateDialog(linkId) { openUpdateDialog(linkId) {
let link = _.findWhere(this.withdrawLinks, {id: linkId}) let link = _.findWhere(this.withdrawLinks, {id: linkId})
@ -258,7 +255,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,32 @@
<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 }}"> <q-badge v-if="spent" color="red" class="q-mb-md"
<lnbits-qrcode >Withdraw is spent.</q-badge
:value="this.here + '/?lightning={{lnurl }}'" >
></lnbits-qrcode> <q-badge v-else-if="!enabled" color="grey" class="q-mb-md"
>Withdraw is disabled.</q-badge
>
<a v-else class="text-secondary" :href="link">
<lnbits-qrcode-lnurl
prefix="lnurlw"
: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,8 +60,11 @@
mixins: [window.windowMixin], mixins: [window.windowMixin],
data() { data() {
return { return {
here: location.protocol + '//' + location.host, spent: {{ 'true' if spent else 'false' }},
nfcTagWriting: false url: '{{ lnurl_url }}',
lnurl: '',
nfcTagWriting: false,
enabled: {{ 'true' if enabled else 'false' }}
} }
} }
}) })

View file

@ -38,6 +38,7 @@
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th <q-th
@ -51,47 +52,46 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width>
<q-icon
name="power_settings_new"
:color="props.row.enabled ? 'green' : 'red'"
size="xs"
>
<q-tooltip>
<span
v-text="props.row.enabled ? 'Withdraw link is enabled' : 'Withdraw link is disabled'"
></span>
</q-tooltip>
</q-icon>
</q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
unelevated unelevated
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 +139,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">
@ -252,6 +252,20 @@
hint="Custom data as JSON string, will get posted along with webhook 'body' field." hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input> ></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="formDialog.data.enabled"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Enable / Disable </q-item-label>
<q-item-label caption
>You can enable or disable these vouchers</q-item-label
>
</q-item-section>
</q-item>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
@ -364,6 +378,20 @@
label="Number of vouchers" label="Number of vouchers"
></q-input> ></q-input>
<q-list> <q-list>
<q-item tag="label" class="rounded-borders">
<q-item-section avatar>
<q-checkbox
v-model="simpleformDialog.data.enabled"
color="primary"
></q-checkbox>
</q-item-section>
<q-item-section>
<q-item-label>Enable / Disable </q-item-label>
<q-item-label caption
>You can enable or disable these vouchers</q-item-label
>
</q-item-section>
</q-item>
<q-item tag="label" class="rounded-borders"> <q-item tag="label" class="rounded-borders">
<q-item-section avatar> <q-item-section avatar>
<q-checkbox <q-checkbox
@ -413,9 +441,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 +470,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

@ -4,23 +4,21 @@
<div class="" id="vue"> <div class="" id="vue">
{% for page in link %} {% for page in link %}
<page size="A4" id="pdfprint"> <page size="A4" id="pdfprint">
<table style="width: 100%"> <div class="full-height content-center">
{% for threes in page %} {% for row in page %}
<tr style="height: 59.4mm"> <div class="row" style="max-height: 54mm">
{% for one in threes %} {% for one in row %}
<td style="width: 105mm"> <div class="col-6">
<center>
<lnbits-qrcode <lnbits-qrcode
style="width: fit-content" style="width: 50mm"
:value="theurl + '/?lightning={{one}}'" :value="theurl + '/?lightning={{one}}'"
:options="{width: 150}" :show-buttons="false"
></lnbits-qrcode> ></lnbits-qrcode>
</center> </div>
</td>
{% endfor %} {% endfor %}
</tr> </div>
{% endfor %} {% endfor %}
</table> </div>
</page> </page>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -11,7 +11,8 @@
<div class="lnurlw"> <div class="lnurlw">
<lnbits-qrcode <lnbits-qrcode
:value="theurl + '/?lightning={{one}}'" :value="theurl + '/?lightning={{one}}'"
:options="{width: 98, margin: 2, logo: false}" :show-buttons="false"
:options="{width: 150}"
></lnbits-qrcode> ></lnbits-qrcode>
</div> </div>
</div> </div>
@ -61,9 +62,10 @@
.wrapper .lnurlw { .wrapper .lnurlw {
display: block; display: block;
position: absolute; position: absolute;
top: calc(7.3mm + 1rem); top: calc(3mm + 1rem);
left: calc(7.5mm + 1rem); left: calc(6mm + 1rem);
transform: rotate(45deg); transform: rotate(45deg);
width: 27mm;
} }
@media print { @media print {
@ -83,8 +85,8 @@
.wrapper .lnurlw { .wrapper .lnurlw {
display: block; display: block;
position: absolute; position: absolute;
top: 7.3mm; top: 3mm;
left: 7.5mm; left: 6mm;
transform: rotate(45deg); transform: rotate(45deg);
} }
} }

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
@ -44,9 +45,9 @@ async def display(request: Request, link_id):
"withdraw/display.html", "withdraw/display.html",
{ {
"request": request, "request": request,
"link": link.json(), "spent": link.is_spent,
"lnurl": lnurl, "lnurl_url": str(lnurl.url),
"unique": True, "enabled": link.enabled,
}, },
) )
@ -58,11 +59,8 @@ 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:
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/print_qr.html", "withdraw/print_qr.html",
{"request": request, "link": link.json(), "unique": False}, {"request": request, "link": link.json(), "unique": False},
@ -83,7 +81,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 +112,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 +131,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

@ -3,7 +3,7 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
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 (
@ -15,7 +15,7 @@ from .crud import (
update_withdraw_link, update_withdraw_link,
) )
from .helpers import create_lnurl from .helpers import create_lnurl
from .models import CreateWithdrawData, HashCheck from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
withdraw_ext_api = APIRouter(prefix="/api/v1") withdraw_ext_api = APIRouter(prefix="/api/v1")
@ -27,38 +27,35 @@ async def api_links(
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) links = await get_withdraw_links(wallet_ids, limit, offset)
data = [] for linkk in links.data:
for link in links:
try: try:
lnurl = create_lnurl(link, request) lnurl = create_lnurl(linkk, request)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc), detail=str(exc),
) from exc ) from exc
data.append({**link.dict(), **{"lnurl": lnurl}}) linkk.lnurl = str(lnurl.bech32)
linkk.lnurl_url = str(lnurl.url)
return { return links
"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,
request: Request, request: Request,
link_id: str,
key_info: WalletTypeInfo = Depends(require_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
): ) -> WithdrawLink:
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
if not link: if not link:
@ -70,6 +67,7 @@ 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: try:
lnurl = create_lnurl(link, request) lnurl = create_lnurl(link, request)
except ValueError as exc: except ValueError as exc:
@ -77,17 +75,19 @@ async def api_link_retrieve(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(exc), detail=str(exc),
) from exc ) from exc
return {**link.dict(), **{"lnurl": lnurl}} link.lnurl = str(lnurl.bech32)
link.lnurl_url = str(lnurl.url)
return link
@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, 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)
@ -159,7 +159,6 @@ async def api_link_create_or_update(
link = await update_withdraw_link(link) link = await update_withdraw_link(link)
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: try:
lnurl = create_lnurl(link, request) lnurl = create_lnurl(link, request)
except ValueError as exc: except ValueError as exc:
@ -168,13 +167,16 @@ async def api_link_create_or_update(
detail=str(exc), detail=str(exc),
) from exc ) from exc
return {**link.dict(), **{"lnurl": lnurl}} link.lnurl = str(lnurl.bech32)
link.lnurl_url = str(lnurl.url)
return link
@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 +190,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(

View file

@ -3,6 +3,7 @@ from datetime import datetime
import httpx import httpx
import shortuuid import shortuuid
from bolt11 import decode as decode_bolt11
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from lnbits.core.crud import update_payment from lnbits.core.crud import update_payment
@ -43,6 +44,9 @@ async def api_lnurl_response(
if not link: if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.") return LnurlErrorResponse(reason="Withdraw link does not exist.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.") return LnurlErrorResponse(reason="Withdraw is spent.")
@ -86,11 +90,23 @@ async def api_lnurl_callback(
pr: str, pr: str,
id_unique_hash: str | None = None, id_unique_hash: str | None = None,
) -> LnurlErrorResponse | LnurlSuccessResponse: ) -> LnurlErrorResponse | LnurlSuccessResponse:
link = await get_withdraw_link_by_hash(unique_hash) link = await get_withdraw_link_by_hash(unique_hash)
if not link: if not link:
return LnurlErrorResponse(reason="withdraw link not found.") return LnurlErrorResponse(reason="withdraw link not found.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
bolt11 = decode_bolt11(pr)
if not bolt11.amount_msat:
return LnurlErrorResponse(reason="0 amount invoices are not supported.")
if (
link.min_withdrawable * 1000 > bolt11.amount_msat
or bolt11.amount_msat > link.max_withdrawable * 1000
):
return LnurlErrorResponse(reason="Amount not within limits.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="withdraw is spent.") return LnurlErrorResponse(reason="withdraw is spent.")
@ -99,9 +115,9 @@ async def api_lnurl_callback(
now = int(datetime.now().timestamp()) now = int(datetime.now().timestamp())
if now < link.open_time: if now < link.open_time + link.wait_time:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"wait link open_time {link.open_time - now} seconds." reason=f"Wait {link.open_time + link.wait_time - now} seconds."
) )
if not id_unique_hash and link.is_unique: if not id_unique_hash and link.is_unique:
@ -194,6 +210,9 @@ async def api_lnurl_multi_response(
if not link: if not link:
return LnurlErrorResponse(reason="Withdraw link does not exist.") return LnurlErrorResponse(reason="Withdraw link does not exist.")
if not link.enabled:
return LnurlErrorResponse(reason="Withdraw link is disabled.")
if link.is_spent: if link.is_spent:
return LnurlErrorResponse(reason="Withdraw is spent.") return LnurlErrorResponse(reason="Withdraw is spent.")