lnurlp: accept comments, USD prices, min/max ranges.

This commit is contained in:
fiatjaf 2020-10-22 15:58:15 -03:00
parent 2552fd8fc9
commit 2863653261
10 changed files with 495 additions and 322 deletions

View file

@ -1,7 +1,9 @@
import json
from typing import List, Optional, Union from typing import List, Optional, Union
from lnbits import bolt11
from lnbits.db import open_ext_db from lnbits.db import open_ext_db
from lnbits.core.models import Payment
from quart import g
from .models import PayLink from .models import PayLink
@ -10,7 +12,10 @@ def create_pay_link(
*, *,
wallet_id: str, wallet_id: str,
description: str, description: str,
amount: int, min: int,
max: int,
comment_chars: int = 0,
currency: Optional[str] = None,
webhook_url: Optional[str] = None, webhook_url: Optional[str] = None,
success_text: Optional[str] = None, success_text: Optional[str] = None,
success_url: Optional[str] = None, success_url: Optional[str] = None,
@ -21,16 +26,29 @@ def create_pay_link(
INSERT INTO pay_links ( INSERT INTO pay_links (
wallet, wallet,
description, description,
amount, min,
max,
served_meta, served_meta,
served_pr, served_pr,
webhook_url, webhook_url,
success_text, success_text,
success_url success_url,
comment_chars,
currency
) )
VALUES (?, ?, ?, 0, 0, ?, ?, ?) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
""", """,
(wallet_id, description, amount, webhook_url, success_text, success_url), (
wallet_id,
description,
min,
max,
webhook_url,
success_text,
success_url,
comment_chars,
currency,
),
) )
link_id = db.cursor.lastrowid link_id = db.cursor.lastrowid
return get_pay_link(link_id) return get_pay_link(link_id)
@ -43,22 +61,6 @@ def get_pay_link(link_id: int) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None return PayLink.from_row(row) if row else None
def get_pay_link_by_invoice(payment_hash: str) -> Optional[PayLink]:
# this excludes invoices with webhooks that have been sent already
with open_ext_db("lnurlp") as db:
row = db.fetchone(
"""
SELECT pay_links.* FROM pay_links
INNER JOIN invoices ON invoices.pay_link = pay_links.id
WHERE payment_hash = ? AND webhook_sent IS NULL
""",
(payment_hash,),
)
return PayLink.from_row(row) if row else None
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
@ -101,25 +103,12 @@ def delete_pay_link(link_id: int) -> None:
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
def save_link_invoice(link_id: int, payment_request: str) -> None: def mark_webhook_sent(payment: Payment, status: int) -> None:
inv = bolt11.decode(payment_request) payment.extra["wh_status"] = status
g.db.execute(
with open_ext_db("lnurlp") as db:
db.execute(
""" """
INSERT INTO invoices (pay_link, payment_hash, expiry) UPDATE apipayments SET extra = ?
VALUES (?, ?, ?) WHERE hash = ?
""", """,
(link_id, inv.payment_hash, inv.expiry), (json.dumps(payment.extra), payment.payment_hash),
)
def mark_webhook_sent(payment_hash: str, status: int) -> None:
with open_ext_db("lnurlp") as db:
db.execute(
"""
UPDATE invoices SET webhook_sent = ?
WHERE payment_hash = ?
""",
(status, payment_hash),
) )

View file

@ -0,0 +1,48 @@
import trio # type: ignore
import httpx
async def get_fiat_rate(currency: str):
assert currency == "USD", "Only USD is supported as fiat currency."
return await get_usd_rate()
async def get_usd_rate():
"""
Returns an average satoshi price from multiple sources.
"""
satoshi_prices = [None, None, None]
async def fetch_price(index, url, getter):
try:
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
satoshi_price = int(100_000_000 / float(getter(r.json())))
satoshi_prices[index] = satoshi_price
except Exception:
pass
async with trio.open_nursery() as nursery:
nursery.start_soon(
fetch_price,
0,
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
)
nursery.start_soon(
fetch_price,
1,
"https://www.bitstamp.net/api/v2/ticker/btcusd",
lambda d: d["last"],
)
nursery.start_soon(
fetch_price,
2,
"https://api.coincap.io/v2/rates/bitcoin",
lambda d: d["data"]["rateUsd"],
)
satoshi_prices = [x for x in satoshi_prices if x]
return sum(satoshi_prices) / len(satoshi_prices)

View file

@ -1,12 +1,14 @@
import hashlib import hashlib
import math
from http import HTTPStatus from http import HTTPStatus
from quart import jsonify, url_for from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from . import lnurlp_ext from . import lnurlp_ext
from .crud import increment_pay_link, save_link_invoice from .crud import increment_pay_link
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"]) @lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
@ -15,16 +17,19 @@ async def api_lnurl_response(link_id):
if not link: if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True) rate = await get_fiat_rate(link.currency) if link.currency else 1
resp = LnurlPayResponse( resp = LnurlPayResponse(
callback=url, callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
min_sendable=link.amount * 1000, min_sendable=math.ceil(link.min * rate) * 1000,
max_sendable=link.amount * 1000, max_sendable=round(link.max * rate) * 1000,
metadata=link.lnurlpay_metadata, metadata=link.lnurlpay_metadata,
) )
params = resp.dict()
return jsonify(resp.dict()), HTTPStatus.OK if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
return jsonify(params), HTTPStatus.OK
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"]) @lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
@ -33,15 +38,43 @@ async def api_lnurl_callback(link_id):
if not link: if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
payment_hash, payment_request = create_invoice( min, max = link.min, link.max
wallet_id=link.wallet, rate = await get_fiat_rate(link.currency) if link.currency else 1
amount=link.amount, if link.currency:
memo=link.description, # allow some fluctuation (as the fiat price may have changed between the calls)
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(), min = rate * 995 * link.min
extra={"tag": "lnurlp"}, max = rate * 1010 * link.max
amount_received = int(request.args.get("amount"))
if amount_received < min:
return (
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()),
HTTPStatus.OK,
)
elif amount_received > max:
return (
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()),
HTTPStatus.OK,
) )
save_link_invoice(link_id, payment_request) comment = request.args.get("comment")
if len(comment or "") > link.comment_chars:
return (
jsonify(
LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
).dict()
),
HTTPStatus.OK,
)
payment_hash, payment_request = create_invoice(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
)
resp = LnurlPayActionResponse( resp = LnurlPayActionResponse(
pr=payment_request, pr=payment_request,

View file

@ -33,3 +33,16 @@ def m002_webhooks_and_success_actions(db):
); );
""" """
) )
def m003_min_max_comment_fiat(db):
"""
Support for min/max amounts, comments and fiat prices that get
converted automatically to satoshis based on some API.
"""
db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis
db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;")
db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
db.execute("UPDATE pay_links SET max = min;")
db.execute("DROP TABLE invoices")

View file

@ -12,12 +12,15 @@ class PayLink(NamedTuple):
id: int id: int
wallet: str wallet: str
description: str description: str
amount: int min: int
served_meta: int served_meta: int
served_pr: int served_pr: int
webhook_url: str webhook_url: str
success_text: str success_text: str
success_url: str success_url: str
currency: str
comment_chars: int
max: int
@classmethod @classmethod
def from_row(cls, row: Row) -> "PayLink": def from_row(cls, row: Row) -> "PayLink":

View file

@ -0,0 +1,207 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var mapPayLink = obj => {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
fiatRates: {},
checker: null,
payLinks: [],
payLinksTable: {
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
fixedAmount: true,
data: {}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getPayLinks() {
LNbits.api
.request(
'GET',
'/lnurlp/api/v1/links?all_wallets',
this.g.user.wallets[0].inkey
)
.then(response => {
this.payLinks = response.data.map(mapPayLink)
})
.catch(err => {
clearInterval(this.checker)
LNbits.utils.notifyApiError(err)
})
},
closeFormDialog() {},
openQrCodeDialog(linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency)
this.qrCodeDialog.data = {
id: link.id,
amount:
(link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
' ' +
(link.currency || 'sat'),
currency: link.currency,
comments: link.comment_chars
? `${link.comment_chars} characters`
: 'no',
webhook: link.webhook_url || 'nowhere',
success:
link.success_text || link.success_url
? 'Display message "' +
link.success_text +
'"' +
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
: 'do nothing',
lnurl: link.lnurl,
pay_url: link.pay_url,
print_url: link.print_url
}
this.qrCodeDialog.show = true
},
openUpdateDialog(linkId) {
const link = _.findWhere(this.payLinks, {id: linkId})
if (link.currency) this.updateFiatRate(link.currency)
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
this.formDialog.fixedAmount =
this.formDialog.data.min === this.formDialog.data.max
},
sendFormData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
if (this.formDialog.fixedAmount) data.max = data.min
if (data.currency === 'satoshis') data.currency = null
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
if (data.id) {
this.updatePayLink(wallet, data)
} else {
this.createPayLink(wallet, data)
}
},
updatePayLink(wallet, data) {
let values = _.omit(
_.pick(
data,
'description',
'min',
'max',
'webhook_url',
'success_text',
'success_url',
'comment_chars',
'currency'
),
(value, key) =>
(key === 'webhook_url' ||
key === 'success_text' ||
key === 'success_url') &&
(value === null || value === '')
)
LNbits.api
.request(
'PUT',
'/lnurlp/api/v1/links/' + data.id,
wallet.adminkey,
values
)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
this.payLinks.push(mapPayLink(response.data))
this.formDialog.show = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
createPayLink(wallet, data) {
LNbits.api
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
.then(response => {
this.payLinks.push(mapPayLink(response.data))
this.formDialog.show = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deletePayLink: linkId => {
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/lnurlp/api/v1/links/' + linkId,
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
)
.then(response => {
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
},
updateFiatRate(currency) {
LNbits.api
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
.then(response => {
let rates = _.clone(this.fiatRates)
rates[currency] = response.data.rate
this.fiatRates = rates
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created() {
if (this.g.user.wallets.length) {
var getPayLinks = this.getPayLinks
getPayLinks()
this.checker = setInterval(() => {
getPayLinks()
}, 20000)
}
}
})

View file

@ -4,7 +4,7 @@ import httpx
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.tasks import run_on_pseudo_request, register_invoice_listener from lnbits.tasks import run_on_pseudo_request, register_invoice_listener
from .crud import get_pay_link_by_invoice, mark_webhook_sent from .crud import mark_webhook_sent, get_pay_link
async def register_listeners(): async def register_listeners():
@ -19,13 +19,16 @@ async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
islnurlp = "lnurlp" == payment.extra.get("tag") if "lnurlp" != payment.extra.get("tag"):
if islnurlp: # not an lnurlp invoice
pay_link = get_pay_link_by_invoice(payment.payment_hash)
if not pay_link:
# no pay_link or this webhook has already been sent
return return
if pay_link.webhook_url:
if payment.extra.get("wh_status"):
# this webhook has already been sent
return
pay_link = get_pay_link(payment.extra.get("link", -1))
if pay_link and pay_link.webhook_url:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
r = await client.post( r = await client.post(
@ -34,10 +37,11 @@ async def on_invoice_paid(payment: Payment) -> None:
"payment_hash": payment.payment_hash, "payment_hash": payment.payment_hash,
"payment_request": payment.bolt11, "payment_request": payment.bolt11,
"amount": payment.amount, "amount": payment.amount,
"comment": payment.extra.get("comment"),
"lnurlp": pay_link.id, "lnurlp": pay_link.id,
}, },
timeout=40, timeout=40,
) )
mark_webhook_sent(payment.payment_hash, r.status_code) mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError):
mark_webhook_sent(payment.payment_hash, -1) mark_webhook_sent(payment, -1)

View file

@ -16,25 +16,22 @@
<div class="col"> <div class="col">
<h5 class="text-subtitle1 q-my-none">Pay links</h5> <h5 class="text-subtitle1 q-my-none">Pay links</h5>
</div> </div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div> </div>
<q-table <q-table
dense dense
flat flat
:data="payLinks" :data="payLinks"
row-key="id" row-key="id"
:columns="payLinksTable.columns"
:pagination.sync="payLinksTable.pagination" :pagination.sync="payLinksTable.pagination"
> >
{% raw %} {% 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 v-for="col in props.cols" :key="col.name" :props="props"> <q-th auto-width>Description</q-th>
{{ col.label }} <q-th auto-width>Amount</q-th>
</q-th> <q-th auto-width>Currency</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th> <q-th auto-width></q-th>
</q-tr> </q-tr>
</template> </template>
@ -60,8 +57,39 @@
@click="openQrCodeDialog(props.row.id)" @click="openQrCodeDialog(props.row.id)"
></q-btn> ></q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td auto-width>{{ props.row.description }}</q-td>
{{ col.value }} <q-td auto-width>
<span v-if="props.row.min == props.row.max">
{{ props.row.min }}
</span>
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
</q-td>
<q-td>{{ props.row.currency || 'sat' }}</q-td>
<q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.success_text || props.row.success_url"
size="14px"
name="call_to_action"
>
<q-tooltip>
On success, show message '{{ props.row.success_text }}'
<span v-if="props.row.success_url"
>and URL '{{ props.row.success_url }}'</span
>
</q-tooltip>
</q-icon>
<q-icon
v-if="props.row.comment_chars > 0"
size="14px"
name="insert_comment"
>
<q-tooltip>
{{ props.row.comment_chars }}-char comment allowed
</q-tooltip>
</q-icon>
</q-td> </q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
@ -124,12 +152,52 @@
type="text" type="text"
label="Item description *" label="Item description *"
></q-input> ></q-input>
<div class="row q-col-gutter-sm">
<q-input <q-input
filled filled
dense dense
v-model.number="formDialog.data.amount" v-model.number="formDialog.data.min"
type="number" type="number"
label="Amount (sat) *" :step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
></q-input>
<q-input
v-if="!formDialog.fixedAmount"
filled
dense
v-model.number="formDialog.data.max"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
label="Max *"
></q-input>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-checkbox
dense
v-model="formDialog.fixedAmount"
label="Fixed amount"
/>
</div>
<div class="col">
<q-select
dense
:options='["satoshis", "USD"]'
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
@input="updateFiatRate"
/>
</div>
</div>
<q-input
filled
dense
v-model.number="formDialog.data.comment_chars"
type="number"
label="Comment maximum characters"
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
></q-input> ></q-input>
<q-input <q-input
filled filled
@ -171,8 +239,8 @@
formDialog.data.wallet == null || formDialog.data.wallet == null ||
formDialog.data.description == null || formDialog.data.description == null ||
( (
formDialog.data.amount == null || formDialog.data.min == null ||
formDialog.data.amount < 1 formDialog.data.min <= 0
) )
" "
type="submit" type="submit"
@ -198,11 +266,16 @@
</q-responsive> </q-responsive>
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> <strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br /> <strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<strong>Webhook:</strong> {{ qrCodeDialog.data.webhook_url }}<br /> <span v-if="qrCodeDialog.data.currency"
<strong>Success Message:</strong> {{ qrCodeDialog.data.success_text ><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
fiatRates[qrCodeDialog.data.currency] ?
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
/></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br /> }}<br />
<strong>Success URL:</strong> {{ qrCodeDialog.data.success_url }}<br /> <strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
</p> </p>
{% endraw %} {% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">
@ -220,7 +293,6 @@
>Shareable link</q-btn >Shareable link</q-btn
> >
<q-btn <q-btn
v-if="!qrCodeDialog.data.is_unique"
outline outline
color="grey" color="grey"
icon="print" icon="print"
@ -234,223 +306,5 @@
</q-dialog> </q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script src="/lnurlp/static/js/index.js"></script>
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.host,
window.location.pathname
].join('')
var mapPayLink = function (obj) {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
checker: null,
payLinks: [],
payLinksTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'amount',
align: 'right',
label: 'Amount (sat)',
field: 'amount'
},
{
name: 'webhook_url',
align: 'left',
label: 'Webhook URL',
field: 'webhook_url'
},
{
name: 'success_action',
align: 'center',
label: '',
format: (_, row) =>
row.success_text || row.success_url ? '💬' : ''
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: {
is_unique: false
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getPayLinks: function () {
var self = this
LNbits.api
.request(
'GET',
'/lnurlp/api/v1/links?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.payLinks = response.data.map(function (obj) {
return mapPayLink(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.show = true
},
openUpdateDialog: function (linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
data.wait_time =
data.wait_time *
{
seconds: 1,
minutes: 60,
hours: 3600
}[this.formDialog.secondMultiplier]
if (data.id) {
this.updatePayLink(wallet, data)
} else {
this.createPayLink(wallet, data)
}
},
updatePayLink: function (wallet, data) {
var self = this
let values = _.omit(
_.pick(
data,
'description',
'amount',
'webhook_url',
'success_text',
'success_url'
),
(value, key) =>
(key === 'webhook_url' ||
key === 'success_text' ||
key === 'success_url') &&
(value === null || value === '')
)
LNbits.api
.request(
'PUT',
'/lnurlp/api/v1/links/' + data.id,
wallet.adminkey,
values
)
.then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) {
return obj.id === data.id
})
self.payLinks.push(mapPayLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createPayLink: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
.then(function (response) {
self.payLinks.push(mapPayLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deletePayLink: function (linkId) {
var self = this
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/lnurlp/api/v1/links/' + linkId,
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey
)
.then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) {
return obj.id === linkId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
}
},
created: function () {
if (this.g.user.wallets.length) {
var getPayLinks = this.getPayLinks
getPayLinks()
this.checker = setInterval(function () {
getPayLinks()
}, 20000)
}
}
})
</script>
{% endblock %} {% endblock %}

View file

@ -1,11 +1,11 @@
from quart import g, jsonify, request from quart import g, jsonify, request
from http import HTTPStatus from http import HTTPStatus
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.extensions.lnurlp import lnurlp_ext from lnbits.extensions.lnurlp import lnurlp_ext # type: ignore
from .crud import ( from .crud import (
create_pay_link, create_pay_link,
get_pay_link, get_pay_link,
@ -13,6 +13,7 @@ from .crud import (
update_pay_link, update_pay_link,
delete_pay_link, delete_pay_link,
) )
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/links", methods=["GET"]) @lnurlp_ext.route("/api/v1/links", methods=["GET"])
@ -55,13 +56,24 @@ async def api_link_retrieve(link_id):
@api_validate_post_request( @api_validate_post_request(
schema={ schema={
"description": {"type": "string", "empty": False, "required": True}, "description": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 1, "required": True}, "min": {"type": "number", "min": 0.01, "required": True},
"max": {"type": "number", "min": 0.01, "required": True},
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
"webhook_url": {"type": "string", "required": False}, "webhook_url": {"type": "string", "required": False},
"success_text": {"type": "string", "required": False}, "success_text": {"type": "string", "required": False},
"success_url": {"type": "string", "required": False}, "success_url": {"type": "string", "required": False},
} }
) )
async def api_link_create_or_update(link_id=None): async def api_link_create_or_update(link_id=None):
if g.data["min"] > g.data["max"]:
return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST
if g.data.get("currency") == None and (
round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"]
):
return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
if link_id: if link_id:
link = get_pay_link(link_id) link = get_pay_link(link_id)
@ -92,3 +104,13 @@ async def api_link_delete(link_id):
delete_pay_link(link_id) delete_pay_link(link_id)
return "", HTTPStatus.NO_CONTENT return "", HTTPStatus.NO_CONTENT
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
async def api_check_fiat_rate(currency):
try:
rate = await get_fiat_rate(currency)
except AssertionError:
rate = None
return jsonify({"rate": rate}), HTTPStatus.OK

View file

@ -1,6 +1,6 @@
{% 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 scripts %} {{ window_vars(user) }}
<script type="text/javascript" src="/withdraw/static/js/index.js"></script> <script src="/withdraw/static/js/index.js"></script>
{% endblock %} {% block page %} {% 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">