Merge pull request #53 from lnbits/update-v1
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled

feat: update to lnbits 1.0.0
This commit is contained in:
Vlad Stan 2024-11-12 11:25:14 +02:00 committed by GitHub
commit 432ed5299a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1499 additions and 1455 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",
"min_lnbits_version": "0.12.11", "min_lnbits_version": "1.0.0",
"contributors": [ "contributors": [
{ {
"name": "arcbtc", "name": "arcbtc",

194
crud.py
View file

@ -1,6 +1,5 @@
from datetime import datetime from datetime import datetime
from time import time from typing import Optional
from typing import List, Optional, Tuple
import shortuuid import shortuuid
from lnbits.db import Database from lnbits.db import Database
@ -16,107 +15,78 @@ async def create_withdraw_link(
) -> WithdrawLink: ) -> WithdrawLink:
link_id = urlsafe_short_hash()[:22] link_id = urlsafe_short_hash()[:22]
available_links = ",".join([str(i) for i in range(data.uses)]) available_links = ",".join([str(i) for i in range(data.uses)])
await db.execute( withdraw_link = WithdrawLink(
f""" id=link_id,
INSERT INTO withdraw.withdraw_link ( wallet=wallet_id,
id, unique_hash=urlsafe_short_hash(),
wallet, k1=urlsafe_short_hash(),
title, created_at=datetime.now(),
min_withdrawable, open_time=int(datetime.now().timestamp()) + data.wait_time,
max_withdrawable, title=data.title,
uses, min_withdrawable=data.min_withdrawable,
wait_time, max_withdrawable=data.max_withdrawable,
is_unique, uses=data.uses,
unique_hash, wait_time=data.wait_time,
k1, is_unique=data.is_unique,
open_time, usescsv=available_links,
usescsv, webhook_url=data.webhook_url,
webhook_url, webhook_headers=data.webhook_headers,
webhook_headers, webhook_body=data.webhook_body,
webhook_body, custom_url=data.custom_url,
custom_url, number=0,
created_at
) )
VALUES await db.insert("withdraw.withdraw_link", withdraw_link)
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, {db.timestamp_placeholder}) return withdraw_link
""",
(
link_id,
wallet_id,
data.title,
data.min_withdrawable,
data.max_withdrawable,
data.uses,
data.wait_time,
int(data.is_unique),
urlsafe_short_hash(),
urlsafe_short_hash(),
int(datetime.now().timestamp()) + data.wait_time,
available_links,
data.webhook_url,
data.webhook_headers,
data.webhook_body,
data.custom_url,
int(time()),
),
)
link = await get_withdraw_link(link_id, 0)
assert link, "Newly created link couldn't be retrieved"
return link
async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]:
row = await db.fetchone( link = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) "SELECT * FROM withdraw.withdraw_link WHERE id = :id",
{"id": link_id},
WithdrawLink,
) )
if not row: if not link:
return None return None
link = dict(**row) link.number = num
link["number"] = num return link
return WithdrawLink.parse_obj(link)
async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]: async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]:
row = await db.fetchone( link = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,) "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = :hash",
{"hash": unique_hash},
WithdrawLink,
) )
if not row: if not link:
return None return None
link = dict(**row) link.number = num
link["number"] = num return link
return WithdrawLink.parse_obj(link)
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]: ) -> tuple[list[WithdrawLink], int]:
rows = await db.fetchall( q = ",".join([f"'{w}'" for w in wallet_ids])
""" links = await db.fetchall(
SELECT * FROM withdraw.withdraw_link f"""
WHERE wallet IN ({}) SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q})
ORDER BY open_time DESC ORDER BY open_time DESC LIMIT :limit OFFSET :offset
LIMIT ? OFFSET ? """,
""".format( {"limit": limit, "offset": offset},
",".join("?" * len(wallet_ids)) WithdrawLink,
),
(*wallet_ids, limit, offset),
) )
total = await db.fetchone( result = await db.execute(
""" f"""
SELECT COUNT(*) as total FROM withdraw.withdraw_link SELECT COUNT(*) as total FROM withdraw.withdraw_link
WHERE wallet IN ({}) WHERE wallet IN ({q})
""".format( """
",".join("?" * len(wallet_ids))
),
(*wallet_ids,),
) )
total = result.mappings().first()
return [WithdrawLink(**row) for row in rows], total["total"] return links, total.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:
@ -125,36 +95,25 @@ async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> N
for x in link.usescsv.split(",") for x in link.usescsv.split(",")
if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
] ]
await update_withdraw_link( link.usescsv = ",".join(unique_links)
link.id, await update_withdraw_link(link)
usescsv=",".join(unique_links),
)
async def increment_withdraw_link(link: WithdrawLink) -> None: async def increment_withdraw_link(link: WithdrawLink) -> None:
await update_withdraw_link( link.used = link.used + 1
link.id, link.open_time = int(datetime.now().timestamp()) + link.wait_time
used=link.used + 1, await update_withdraw_link(link)
open_time=link.wait_time + int(datetime.now().timestamp()),
)
async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]: async def update_withdraw_link(link: WithdrawLink) -> WithdrawLink:
if "is_unique" in kwargs: await db.update("withdraw.withdraw_link", link)
kwargs["is_unique"] = int(kwargs["is_unique"]) return link
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE withdraw.withdraw_link SET {q} WHERE id = ?",
(*kwargs.values(), link_id),
)
row = await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,)
)
return WithdrawLink(**row) if row else None
async def delete_withdraw_link(link_id: str) -> None: async def delete_withdraw_link(link_id: str) -> None:
await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) await db.execute(
"DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id}
)
def chunks(lst, n): def chunks(lst, n):
@ -165,30 +124,29 @@ def chunks(lst, n):
async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
await db.execute( await db.execute(
""" """
INSERT INTO withdraw.hash_check ( INSERT INTO withdraw.hash_check (id, lnurl_id)
id, VALUES (:id, :lnurl_id)
lnurl_id
)
VALUES (?, ?)
""", """,
(the_hash, lnurl_id), {"id": the_hash, "lnurl_id": lnurl_id},
) )
hash_check = await get_hash_check(the_hash, lnurl_id) hash_check = await get_hash_check(the_hash, lnurl_id)
return hash_check return hash_check
async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
rowid = await db.fetchone( hash_check = await db.fetchone(
"SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,) "SELECT * FROM withdraw.hash_check WHERE id = :id", {"id": the_hash}, HashCheck
) )
rowlnurl = await db.fetchone( hash_check_lnurl = await db.fetchone(
"SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,) "SELECT * FROM withdraw.hash_check WHERE lnurl_id = :id",
{"id": lnurl_id},
HashCheck,
) )
if not rowlnurl: if not hash_check_lnurl:
await create_hash_check(the_hash, lnurl_id) await create_hash_check(the_hash, lnurl_id)
return HashCheck(lnurl=True, hash=False) return HashCheck(lnurl=True, hash=False)
else: else:
if not rowid: if not hash_check:
await create_hash_check(the_hash, lnurl_id) await create_hash_check(the_hash, lnurl_id)
return HashCheck(lnurl=True, hash=False) return HashCheck(lnurl=True, hash=False)
else: else:
@ -196,4 +154,6 @@ async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
async def delete_hash_check(the_hash: str) -> None: async def delete_hash_check(the_hash: str) -> None:
await db.execute("DELETE FROM withdraw.hash_check WHERE id = ?", (the_hash,)) await db.execute(
"DELETE FROM withdraw.hash_check WHERE id = :hash", {"hash": the_hash}
)

View file

@ -1,6 +1,3 @@
from time import time
async def m001_initial(db): async def m001_initial(db):
""" """
Creates an improved withdraw table and migrates the existing data. Creates an improved withdraw table and migrates the existing data.
@ -142,10 +139,3 @@ 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}"
) )
# Set created_at to current time for all existing rows
await db.execute(
f"""
UPDATE withdraw.withdraw_link SET created_at = {db.timestamp_placeholder}
""",
(int(time()),),
)

View file

@ -1,17 +1,15 @@
import datetime from datetime import datetime
import shortuuid import shortuuid
from fastapi import Query, Request from fastapi import Query, Request
from lnurl import (
# TODO remove type: ignore when 0.12.11 is released ClearnetUrl,
from lnurl import ( # type: ignore
ClearnetUrl, # type: ignore
Lnurl, Lnurl,
LnurlWithdrawResponse, LnurlWithdrawResponse,
MilliSatoshi, # type: ignore MilliSatoshi,
) )
from lnurl import encode as lnurl_encode from lnurl import encode as lnurl_encode
from pydantic import BaseModel from pydantic import BaseModel, Field
class CreateWithdrawData(BaseModel): class CreateWithdrawData(BaseModel):
@ -29,7 +27,6 @@ class CreateWithdrawData(BaseModel):
class WithdrawLink(BaseModel): class WithdrawLink(BaseModel):
id: str id: str
created_at: datetime.datetime
wallet: str = Query(None) wallet: str = Query(None)
title: str = Query(None) title: str = Query(None)
min_withdrawable: int = Query(0) min_withdrawable: int = Query(0)
@ -42,11 +39,12 @@ class WithdrawLink(BaseModel):
open_time: int = Query(0) open_time: int = Query(0)
used: int = Query(0) used: int = Query(0)
usescsv: str = Query(None) usescsv: str = Query(None)
number: int = Query(0) number: int = Field(default=0, no_database=True)
webhook_url: str = Query(None) webhook_url: str = Query(None)
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)
created_at: datetime
@property @property
def is_spent(self) -> bool: def is_spent(self) -> bool:

2428
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10 | ^3.9" python = "^3.10 | ^3.9"
lnbits = "*" lnbits = {version = "*", allow-prereleases = true}
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^24.3.0" black = "^24.3.0"

View file

@ -1,15 +1,11 @@
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ const locationPath = [
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol, window.location.protocol,
'//', '//',
window.location.host, window.location.host,
window.location.pathname window.location.pathname
].join('') ].join('')
var 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.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable)
obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable) obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable)
@ -22,10 +18,10 @@ var mapWithdrawLink = function (obj) {
const CUSTOM_URL = '/static/images/default_voucher.png' const CUSTOM_URL = '/static/images/default_voucher.png'
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data() {
return { return {
checker: null, checker: null,
withdrawLinks: [], withdrawLinks: [],
@ -97,14 +93,14 @@ new Vue({
} }
}, },
computed: { computed: {
sortedWithdrawLinks: function () { sortedWithdrawLinks() {
return this.withdrawLinks.sort(function (a, b) { return this.withdrawLinks.sort(function (a, b) {
return b.uses_left - a.uses_left return b.uses_left - a.uses_left
}) })
} }
}, },
methods: { methods: {
getWithdrawLinks: function (props) { getWithdrawLinks(props) {
if (props) { if (props) {
this.withdrawLinksTable.pagination = props.pagination this.withdrawLinksTable.pagination = props.pagination
} }
@ -115,8 +111,6 @@ new Vue({
offset: (pagination.page - 1) * pagination.rowsPerPage offset: (pagination.page - 1) * pagination.rowsPerPage
} }
var self = this
LNbits.api LNbits.api
.request( .request(
'GET', 'GET',
@ -124,48 +118,46 @@ new Vue({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.withdrawLinks = response.data.data.map(function (obj) { this.withdrawLinks = response.data.data.map(mapWithdrawLink)
return mapWithdrawLink(obj)
})
this.withdrawLinksTable.pagination.rowsNumber = response.data.total this.withdrawLinksTable.pagination.rowsNumber = response.data.total
}) })
.catch(error => { .catch(error => {
clearInterval(self.checker) clearInterval(this.checker)
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
closeFormDialog: function () { closeFormDialog() {
this.formDialog.data = { this.formDialog.data = {
is_unique: false, is_unique: false,
use_custom: false, use_custom: false,
has_webhook: false has_webhook: false
} }
}, },
simplecloseFormDialog: function () { simplecloseFormDialog() {
this.simpleformDialog.data = { this.simpleformDialog.data = {
is_unique: false, is_unique: false,
use_custom: false use_custom: false
} }
}, },
openQrCodeDialog: function (linkId) { openQrCodeDialog(linkId) {
var 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 = this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
}, },
openUpdateDialog: function (linkId) { openUpdateDialog(linkId) {
var link = _.findWhere(this.withdrawLinks, {id: linkId}) let link = _.findWhere(this.withdrawLinks, {id: linkId})
link._data.has_webhook = link._data.webhook_url ? true : false link._data.has_webhook = link._data.webhook_url ? true : false
this.formDialog.data = _.clone(link._data) this.formDialog.data = _.clone(link._data)
this.formDialog.show = true this.formDialog.show = true
}, },
sendFormData: function () { sendFormData() {
var wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
var data = _.omit(this.formDialog.data, 'wallet') const data = _.omit(this.formDialog.data, 'wallet')
if (!data.use_custom) { if (!data.use_custom) {
data.custom_url = null data.custom_url = null
@ -189,11 +181,11 @@ new Vue({
this.createWithdrawLink(wallet, data) this.createWithdrawLink(wallet, data)
} }
}, },
simplesendFormData: function () { simplesendFormData() {
var wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.simpleformDialog.data.wallet id: this.simpleformDialog.data.wallet
}) })
var data = _.omit(this.simpleformDialog.data, 'wallet') const data = _.omit(this.simpleformDialog.data, 'wallet')
data.wait_time = 1 data.wait_time = 1
data.min_withdrawable = data.max_withdrawable data.min_withdrawable = data.max_withdrawable
@ -214,7 +206,7 @@ new Vue({
this.createWithdrawLink(wallet, data) this.createWithdrawLink(wallet, data)
} }
}, },
updateWithdrawLink: function (wallet, data) { updateWithdrawLink(wallet, data) {
// Remove webhook info if toggle is set to false // Remove webhook info if toggle is set to false
if (!data.has_webhook) { if (!data.has_webhook) {
data.webhook_url = null data.webhook_url = null
@ -241,7 +233,7 @@ new Vue({
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
createWithdrawLink: function (wallet, data) { createWithdrawLink(wallet, data) {
LNbits.api LNbits.api
.request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data)
.then(response => { .then(response => {
@ -254,21 +246,20 @@ new Vue({
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
deleteWithdrawLink: function (linkId) { deleteWithdrawLink(linkId) {
var self = this const link = _.findWhere(this.withdrawLinks, {id: linkId})
var link = _.findWhere(this.withdrawLinks, {id: linkId})
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this withdraw link?') .confirmDialog('Are you sure you want to delete this withdraw link?')
.onOk(function () { .onOk(() => {
LNbits.api LNbits.api
.request( .request(
'DELETE', 'DELETE',
'/withdraw/api/v1/links/' + linkId, '/withdraw/api/v1/links/' + linkId,
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
) )
.then(function (response) { .then(response => {
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
return obj.id === linkId return obj.id === linkId
}) })
}) })
@ -277,7 +268,7 @@ new Vue({
}) })
}) })
}, },
writeNfcTag: async function (lnurl) { async writeNfcTag(lnurl) {
try { try {
if (typeof NDEFReader == 'undefined') { if (typeof NDEFReader == 'undefined') {
throw { throw {
@ -321,7 +312,7 @@ new Vue({
) )
} }
}, },
created: function () { created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getWithdrawLinks() this.getWithdrawLinks()
this.checker = setInterval(this.getWithdrawLinks, 300000) this.checker = setInterval(this.getWithdrawLinks, 300000)

View file

@ -24,7 +24,7 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X GET {{ request.base_url }}withdraw/api/v1/links -H >curl -X GET {{ request.base_url }}withdraw/api/v1/links -H
"X-Api-Key: {{ user.wallets[0].inkey }}" "X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -51,8 +51,8 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X GET {{ request.base_url >curl -X GET {{ request.base_url
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{ }}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key:
user.wallets[0].inkey }}" <span v-text="g.user.wallets[0].inkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -86,7 +86,7 @@
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;, "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;,
"webhook_url": &lt;string&gt;}' -H "Content-type: application/json" -H "webhook_url": &lt;string&gt;}' -H "Content-type: application/json" -H
"X-Api-Key: {{ user.wallets[0].adminkey }}" "X-Api-Key: <span v-text="g.user.wallets[0].adminkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -122,8 +122,8 @@
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;, &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;, "max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H "wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{ "Content-type: application/json" -H "X-Api-Key:
user.wallets[0].adminkey }}" <span v-text="g.user.wallets[0].adminkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -147,8 +147,8 @@
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code <code
>curl -X DELETE {{ request.base_url >curl -X DELETE {{ request.base_url
}}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{ }}withdraw/api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key:
user.wallets[0].adminkey }}" <span v-text="g.user.wallets[0].adminkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -176,7 +176,7 @@
<code <code
>curl -X GET {{ request.base_url >curl -X GET {{ request.base_url
}}withdraw/api/v1/links/&lt;the_hash&gt;/&lt;lnurl_id&gt; -H }}withdraw/api/v1/links/&lt;the_hash&gt;/&lt;lnurl_id&gt; -H
"X-Api-Key: {{ user.wallets[0].inkey }}" "X-Api-Key: <span v-text="g.user.wallets[0].inkey"></span>"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>

View file

@ -2,7 +2,7 @@
in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor
%} {% endblock %} {% block scripts %} %} {% endblock %} {% block scripts %}
<script> <script>
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
data: function () { data: function () {
return {} return {}

View file

@ -8,14 +8,9 @@
<q-badge color="red" class="q-mb-md">Withdraw is spent.</q-badge> <q-badge color="red" class="q-mb-md">Withdraw is spent.</q-badge>
{% endif %} {% endif %}
<a class="text-secondary" href="lightning:{{ lnurl }}"> <a class="text-secondary" href="lightning:{{ lnurl }}">
<q-responsive :ratio="1" class="q-mx-md"> <lnbits-qrcode
<qrcode
:value="this.here + '/?lightning={{lnurl }}'" :value="this.here + '/?lightning={{lnurl }}'"
:options="{width: 800}" ></lnbits-qrcode>
class="rounded-borders"
>
</qrcode>
</q-responsive>
</a> </a>
</div> </div>
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
@ -52,12 +47,10 @@
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [window.windowMixin],
data: function () { data() {
return { return {
here: location.protocol + '//' + location.host, here: location.protocol + '//' + location.host,
nfcTagWriting: false nfcTagWriting: false

View file

@ -1,7 +1,5 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} %} {% block page %}
<script src="/withdraw/static/js/index.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
@ -9,7 +7,11 @@
<q-btn unelevated color="primary" @click="simpleformDialog.show = true" <q-btn unelevated color="primary" @click="simpleformDialog.show = true"
>Quick vouchers</q-btn >Quick vouchers</q-btn
> >
<q-btn unelevated color="primary" @click="formDialog.show = true" <q-btn
unelevated
color="primary"
@click="formDialog.show = true"
class="q-ml-md"
>Advanced withdraw link(s)</q-btn >Advanced withdraw link(s)</q-btn
> >
</q-card-section> </q-card-section>
@ -28,20 +30,22 @@
<q-table <q-table
dense dense
flat flat
:data="sortedWithdrawLinks" :rows="sortedWithdrawLinks"
row-key="id" row-key="id"
:columns="withdrawLinksTable.columns" :columns="withdrawLinksTable.columns"
:pagination.sync="withdrawLinksTable.pagination" v-model:pagination="withdrawLinksTable.pagination"
@request="getWithdrawLinks" @request="getWithdrawLinks"
> >
{% raw %}
<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 v-for="col in props.cols" :key="col.name" :props="props"> <q-th
{{ col.label }} v-for="col in props.cols"
</q-th> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
</q-tr> </q-tr>
</template> </template>
@ -110,17 +114,22 @@
color="pink" color="pink"
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td
{{ col.value }} v-for="col in props.cols"
:key="col.name"
:props="props"
v-text="col.value"
>
</q-td> </q-td>
<q-td> <q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http"> <q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip> <q-tooltip
>Webhook to <span v-text="props.row.webhook_url"></span
></q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -404,34 +413,29 @@
<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">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> <lnbits-qrcode
<qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
:options="{width: 800}" ></lnbits-qrcode>
class="rounded-borders"
></qrcode>
{% raw %}
</q-responsive>
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> <strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span <strong>Unique:</strong>
v-if="qrCodeDialog.data.is_unique" <span v-text="qrCodeDialog.data.is_unique"></span>
class="text-deep-purple" <span v-if="qrCodeDialog.data.is_unique" class="text-deep-purple">
>
(QR code will change after each withdrawal)</span (QR code will change after each withdrawal)</span
><br /> ><br />
<strong>Max. withdrawable:</strong> {{ <strong>Max. withdrawable:</strong>
qrCodeDialog.data.max_withdrawable }} sat<br /> <span v-text="qrCodeDialog.data.max_withdrawable"></span> sat<br />
<strong>Wait time:</strong> {{ qrCodeDialog.data.wait_time }} seconds<br /> <strong>Wait time:</strong>
<strong>Withdraws:</strong> {{ qrCodeDialog.data.used }} / {{ <span v-text="qrCodeDialog.data.wait_time"></span> seconds<br />
qrCodeDialog.data.uses }} <strong>Withdraws:</strong>
<span v-text="qrCodeDialog.data.used"></span>/
<span v-text="qrCodeDialog.data.uses"></span><br />
<q-linear-progress <q-linear-progress
:value="qrCodeDialog.data.used / qrCodeDialog.data.uses" :value="qrCodeDialog.data.used / qrCodeDialog.data.uses"
color="primary" color="primary"
class="q-mt-sm" class="q-mt-sm"
></q-linear-progress> ></q-linear-progress>
</p> </p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
<q-btn <q-btn
outline outline
@ -469,4 +473,6 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
{% endblock %}{% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('withdraw/static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -10,10 +10,11 @@
{% for one in threes %} {% for one in threes %}
<td style="width: 105mm"> <td style="width: 105mm">
<center> <center>
<qrcode <lnbits-qrcode
style="width: fit-content"
:value="theurl + '/?lightning={{one}}'" :value="theurl + '/?lightning={{one}}'"
:options="{width: 150}" :options="{width: 150}"
></qrcode> ></lnbits-qrcode>
</center> </center>
</td> </td>
{% endfor %} {% endfor %}
@ -53,11 +54,9 @@
</style> </style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
data: function () { data() {
return { return {
theurl: location.protocol + '//' + location.host, theurl: location.protocol + '//' + location.host,
printDialog: { printDialog: {

View file

@ -6,13 +6,13 @@
<page size="A4" id="pdfprint"> <page size="A4" id="pdfprint">
{% for one in page %} {% for one in page %}
<div class="wrapper"> <div class="wrapper">
<img src="{{custom_url}}" alt="..." /> <img class="lnurlw_design" src="{{custom_url}}" alt="..." />
<span>{{ amt }} sats</span> <span>{{ amt }} sats</span>
<div class="lnurlw"> <div class="lnurlw">
<qrcode <lnbits-qrcode
:value="theurl + '/?lightning={{one}}'" :value="theurl + '/?lightning={{one}}'"
:options="{width: 95, margin: 1}" :options="{width: 98, margin: 2, logo: false}"
></qrcode> ></lnbits-qrcode>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@ -52,7 +52,7 @@
top: calc(3.2mm + 1rem); top: calc(3.2mm + 1rem);
right: calc(4mm + 1rem); right: calc(4mm + 1rem);
} }
.wrapper img { .wrapper img.lnurlw_design {
display: block; display: block;
width: 187mm; width: 187mm;
height: auto; height: auto;
@ -91,9 +91,7 @@
</style> </style>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode) window.app = Vue.createApp({
new Vue({
el: '#vue', el: '#vue',
data: function () { data: function () {
return { return {

View file

@ -20,7 +20,7 @@ def withdraw_renderer():
@withdraw_ext_generic.get("/", response_class=HTMLResponse) @withdraw_ext_generic.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)): async def index(request: Request, user: User = Depends(check_user_exists)):
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/index.html", {"request": request, "user": user.dict()} "withdraw/index.html", {"request": request, "user": user.json()}
) )
@ -36,7 +36,7 @@ async def display(request: Request, link_id):
"withdraw/display.html", "withdraw/display.html",
{ {
"request": request, "request": request,
"link": link.dict(), "link": link.json(),
"lnurl": link.lnurl(req=request), "lnurl": link.lnurl(req=request),
"unique": True, "unique": True,
}, },
@ -83,7 +83,7 @@ async def print_qr(request: Request, link_id):
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/print_qr.html", "withdraw/print_qr.html",
{"request": request, "link": link.dict(), "unique": False}, {"request": request, "link": link.json(), "unique": False},
) )
links = [] links = []
count = 0 count = 0
@ -130,7 +130,7 @@ async def csv(request: Request, link_id):
return withdraw_renderer().TemplateResponse( return withdraw_renderer().TemplateResponse(
"withdraw/csv.html", "withdraw/csv.html",
{"request": request, "link": link.dict(), "unique": False}, {"request": request, "link": link.json(), "unique": False},
) )
links = [] links = []
count = 0 count = 0

View file

@ -4,8 +4,8 @@ from typing import Optional
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.decorators import WalletTypeInfo, get_key_type, require_admin_key from lnbits.core.models import WalletTypeInfo
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnbits.decorators import require_admin_key, require_invoice_key
from .crud import ( from .crud import (
create_withdraw_link, create_withdraw_link,
@ -15,7 +15,7 @@ from .crud import (
get_withdraw_links, get_withdraw_links,
update_withdraw_link, update_withdraw_link,
) )
from .models import CreateWithdrawData from .models import CreateWithdrawData, HashCheck
withdraw_ext_api = APIRouter(prefix="/api/v1") withdraw_ext_api = APIRouter(prefix="/api/v1")
@ -23,37 +23,29 @@ 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(
req: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), 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),
): ):
wallet_ids = [wallet.wallet.id] wallet_ids = [key_info.wallet.id]
if all_wallets: if all_wallets:
user = await get_user(wallet.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 []
try:
links, total = await get_withdraw_links(wallet_ids, limit, offset) links, total = await get_withdraw_links(wallet_ids, limit, offset)
return { return {
"data": [{**link.dict(), **{"lnurl": link.lnurl(req)}} for link in links], "data": [{**link.dict(), **{"lnurl": link.lnurl(req)}} for link in links],
"total": total, "total": total,
} }
except LnurlInvalidUrl as exc:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="""
LNURLs need to be delivered over a publically
accessible `https` domain or Tor.
""",
) from exc
@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, wallet: WalletTypeInfo = Depends(get_key_type) link_id: str,
request: Request,
key_info: WalletTypeInfo = Depends(require_invoice_key),
): ):
link = await get_withdraw_link(link_id, 0) link = await get_withdraw_link(link_id, 0)
@ -62,7 +54,7 @@ async def api_link_retrieve(
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if link.wallet != key_info.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
@ -75,7 +67,7 @@ async def api_link_create_or_update(
req: Request, req: Request,
data: CreateWithdrawData, data: CreateWithdrawData,
link_id: Optional[str] = None, link_id: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_admin_key), key_info: WalletTypeInfo = Depends(require_admin_key),
): ):
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)
@ -115,12 +107,11 @@ async def api_link_create_or_update(
raise HTTPException( raise HTTPException(
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if link.wallet != key_info.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
data_dict = data.dict()
if link.uses > data.uses: if link.uses > data.uses:
if data.uses - link.used <= 0: if data.uses - link.used <= 0:
raise HTTPException( raise HTTPException(
@ -128,33 +119,35 @@ async def api_link_create_or_update(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
) )
numbers = link.usescsv.split(",") numbers = link.usescsv.split(",")
usescsv = ",".join(numbers[: data.uses - link.used]) link.usescsv = ",".join(numbers[: data.uses - link.used])
data_dict["usescsv"] = usescsv
if link.uses < data.uses: if link.uses < data.uses:
numbers = link.usescsv.split(",") numbers = link.usescsv.split(",")
if numbers[-1] == "": if numbers[-1] == "":
current_number = int(link.uses) current_number = int(link.uses)
numbers[-1] = str(link.uses) numbers[-1] = str(link.uses)
else: else:
current_number = int(numbers[-1]) current_number = int(numbers[-1])
while len(numbers) < (data.uses - link.used): while len(numbers) < (data.uses - link.used):
current_number += 1 current_number += 1
numbers.append(str(current_number)) numbers.append(str(current_number))
usescsv = ",".join(numbers) link.usescsv = ",".join(numbers)
data_dict["usescsv"] = usescsv
link = await update_withdraw_link(link_id, **data_dict) for k, v in data.dict().items():
if v is not None:
setattr(link, k, v)
link = await update_withdraw_link(link)
else: else:
link = await create_withdraw_link(wallet_id=wallet.wallet.id, data=data) link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data)
assert link
return {**link.dict(), **{"lnurl": link.lnurl(req)}} return {**link.dict(), **{"lnurl": link.lnurl(req)}}
@withdraw_ext_api.delete("/links/{link_id}", status_code=HTTPStatus.OK) @withdraw_ext_api.delete("/links/{link_id}", status_code=HTTPStatus.OK)
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)): async def api_link_delete(
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
):
link = await get_withdraw_link(link_id) link = await get_withdraw_link(link_id)
if not link: if not link:
@ -162,7 +155,7 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
) )
if link.wallet != wallet.wallet.id: if link.wallet != key_info.wallet.id:
raise HTTPException( raise HTTPException(
detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
) )
@ -174,8 +167,8 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi
@withdraw_ext_api.get( @withdraw_ext_api.get(
"/links/{the_hash}/{lnurl_id}", "/links/{the_hash}/{lnurl_id}",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
dependencies=[Depends(get_key_type)], dependencies=[Depends(require_invoice_key)],
) )
async def api_hash_retrieve(the_hash, lnurl_id): async def api_hash_retrieve(the_hash, lnurl_id) -> HashCheck:
hash_check = await get_hash_check(the_hash, lnurl_id) hash_check = await get_hash_check(the_hash, lnurl_id)
return hash_check return hash_check

View file

@ -9,7 +9,8 @@ import shortuuid
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from lnbits.core.crud import update_payment_extra from lnbits.core.crud import update_payment
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice from lnbits.core.services import pay_invoice
from loguru import logger from loguru import logger
@ -162,7 +163,7 @@ async def api_lnurl_callback(
) from exc ) from exc
try: try:
payment_hash = await pay_invoice( payment = await pay_invoice(
wallet_id=link.wallet, wallet_id=link.wallet,
payment_request=pr, payment_request=pr,
max_sat=link.max_withdrawable, max_sat=link.max_withdrawable,
@ -175,7 +176,7 @@ async def api_lnurl_callback(
await delete_hash_check(id_unique_hash or unique_hash) await delete_hash_check(id_unique_hash or unique_hash)
if link.webhook_url: if link.webhook_url:
await dispatch_webhook(link, payment_hash, pr) await dispatch_webhook(link, payment, pr)
return {"status": "OK"} return {"status": "OK"}
except Exception as exc: except Exception as exc:
# If payment fails, delete the hash stored so another attempt can be made. # If payment fails, delete the hash stored so another attempt can be made.
@ -193,14 +194,14 @@ def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
async def dispatch_webhook( async def dispatch_webhook(
link: WithdrawLink, payment_hash: str, payment_request: str link: WithdrawLink, payment: Payment, payment_request: str
) -> None: ) -> None:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r: httpx.Response = await client.post( r: httpx.Response = await client.post(
link.webhook_url, link.webhook_url,
json={ json={
"payment_hash": payment_hash, "payment_hash": payment.payment_hash,
"payment_request": payment_request, "payment_request": payment_request,
"lnurlw": link.id, "lnurlw": link.id,
"body": json.loads(link.webhook_body) if link.webhook_body else "", "body": json.loads(link.webhook_body) if link.webhook_body else "",
@ -210,24 +211,17 @@ async def dispatch_webhook(
), ),
timeout=40, timeout=40,
) )
await update_payment_extra( payment.extra["wh_success"] = r.is_success
payment_hash=payment_hash, payment.extra["wh_message"] = r.reason_phrase
extra={ payment.extra["wh_response"] = r.text
"wh_success": r.is_success, await update_payment(payment)
"wh_message": r.reason_phrase,
"wh_response": r.text,
},
outgoing=True,
)
except Exception as exc: except Exception as exc:
# webhook fails shouldn't cause the lnurlw to fail # webhook fails shouldn't cause the lnurlw to fail
# since invoice is already paid # since invoice is already paid
logger.error(f"Caught exception when dispatching webhook url: {exc!s}") logger.error(f"Caught exception when dispatching webhook url: {exc!s}")
await update_payment_extra( payment.extra["wh_success"] = False
payment_hash=payment_hash, payment.extra["wh_message"] = str(exc)
extra={"wh_success": False, "wh_message": str(exc)}, await update_payment(payment)
outgoing=True,
)
# FOR LNURLs WHICH ARE UNIQUE # FOR LNURLs WHICH ARE UNIQUE