mirror of https://github.com/lukechilds/lnbits.git
12 changed files with 888 additions and 0 deletions
@ -0,0 +1,14 @@ |
|||
# ATM |
|||
## ATM link maker |
|||
LNURL withdraw is a very powerful tool and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. This functionality has not existed in money before. |
|||
https://github.com/btcontract/lnurl-rfc/blob/master/spec.md#3-lnurl-withdraw |
|||
|
|||
With this extension to can create/edit LNURL withdraws, set a min/max amount, set time (useful for subscription services) |
|||
|
|||
 |
|||
|
|||
|
|||
## API endpoint - /withdraw/api/v1/lnurlmaker |
|||
Easily fetch one-off LNURLw |
|||
|
|||
curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/withdraw/api/v1/lnurlmaker -d '{"amount":"100","memo":"ATM"}' -H "X-Api-Key: YOUR-WALLET-ADMIN-KEY" |
@ -0,0 +1,8 @@ |
|||
from quart import Blueprint |
|||
|
|||
|
|||
offlinelnurlw_ext: Blueprint = Blueprint("offlinelnurlw", __name__, static_folder="static", template_folder="templates") |
|||
|
|||
|
|||
from .views_api import * # noqa |
|||
from .views import * # noqa |
@ -0,0 +1,6 @@ |
|||
{ |
|||
"name": "OfflineLNURLw", |
|||
"short_description": "Offline LNURLw ATM, faucet links", |
|||
"icon": "crop_free", |
|||
"contributors": ["arcbtc"] |
|||
} |
@ -0,0 +1,77 @@ |
|||
from datetime import datetime |
|||
from typing import List, Optional, Union |
|||
from lnbits.db import open_ext_db |
|||
from lnbits.helpers import urlsafe_short_hash |
|||
|
|||
from .models import offlinelnurlwLink |
|||
import ecdsa |
|||
from hashlib import sha256 |
|||
|
|||
|
|||
def create_offlinelnurlw_link( |
|||
title: str, |
|||
wallet_id: str, |
|||
) -> offlinelnurlwLink: |
|||
print("poo") |
|||
with open_ext_db("offlinelnurlw") as db: |
|||
|
|||
link_id = urlsafe_short_hash() |
|||
private_key = urlsafe_short_hash() |
|||
db.execute( |
|||
""" |
|||
INSERT INTO offlinelnurlw_link ( |
|||
id, |
|||
title, |
|||
wallet, |
|||
private_key, |
|||
amount, |
|||
used |
|||
) |
|||
VALUES (?, ?, ?, ?, ?, ?) |
|||
""", |
|||
( |
|||
link_id, |
|||
title, |
|||
wallet_id, |
|||
private_key, |
|||
0, |
|||
0, |
|||
), |
|||
) |
|||
return get_offlinelnurlw_link(link_id, 0) |
|||
|
|||
|
|||
def get_offlinelnurlw_link(link_id: str, hash_id=None, num=0) -> Optional[offlinelnurlwLink]: |
|||
with open_ext_db("offlinelnurlw") as db: |
|||
row = db.fetchone("SELECT * FROM offlinelnurlw_link WHERE id = ?", (link_id,)) |
|||
if hash_id: |
|||
vk = ecdsa.VerifyingKey.from_string(bytes.fromhex(link_id), curve=ecdsa.SECP256k1, hashfunc=sha256) |
|||
if not vk.verify(bytes.fromhex(row[3]), hash_id): |
|||
return None |
|||
|
|||
return offlinelnurlwLink(**row) if row else None |
|||
|
|||
|
|||
def get_offlinelnurlw_links(wallet_ids: Union[str, List[str]]) -> List[offlinelnurlwLink]: |
|||
if isinstance(wallet_ids, str): |
|||
wallet_ids = [wallet_ids] |
|||
|
|||
with open_ext_db("offlinelnurlw") as db: |
|||
q = ",".join(["?"] * len(wallet_ids)) |
|||
rows = db.fetchall(f"SELECT * FROM offlinelnurlw_link WHERE wallet IN ({q})", (*wallet_ids,)) |
|||
|
|||
return [offlinelnurlwLink.from_row(row) for row in rows] |
|||
|
|||
|
|||
def update_offlinelnurlw_link(link_id: str, **kwargs) -> Optional[offlinelnurlwLink]: |
|||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) |
|||
with open_ext_db("offlinelnurlw") as db: |
|||
db.execute(f"UPDATE offlinelnurlw_link SET {q} WHERE id = ?", (*kwargs.values(), link_id)) |
|||
row = db.fetchone("SELECT * FROM offlinelnurlw_link WHERE id = ?", (link_id,)) |
|||
|
|||
return offlinelnurlwLink.from_row(row) if row else None |
|||
|
|||
|
|||
def delete_offlinelnurlw_link(link_id: str) -> None: |
|||
with open_ext_db("offlinelnurlw") as db: |
|||
db.execute("DELETE FROM offlinelnurlw_link WHERE id = ?", (link_id,)) |
@ -0,0 +1,15 @@ |
|||
def m001_initial(db): |
|||
db.execute( |
|||
""" |
|||
CREATE TABLE IF NOT EXISTS offlinelnurlw_link ( |
|||
id TEXT PRIMARY KEY, |
|||
wallet TEXT, |
|||
title TEXT, |
|||
private_key TEXT, |
|||
amount INT, |
|||
used INTEGER DEFAULT 0, |
|||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')) |
|||
); |
|||
""" |
|||
) |
|||
|
@ -0,0 +1,20 @@ |
|||
from quart import url_for |
|||
from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode |
|||
from sqlite3 import Row |
|||
from typing import NamedTuple |
|||
import shortuuid # type: ignore |
|||
|
|||
|
|||
class offlinelnurlwLink(NamedTuple): |
|||
id: str |
|||
wallet: str |
|||
title: str |
|||
private_key: str |
|||
amount: int |
|||
used: int |
|||
time: int |
|||
|
|||
@classmethod |
|||
def from_row(cls, row: Row) -> "offlinelnurlwLink": |
|||
data = dict(row) |
|||
return cls(**data) |
@ -0,0 +1,217 @@ |
|||
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ |
|||
|
|||
Vue.component(VueQrcode.name, VueQrcode) |
|||
|
|||
var locationPath = [ |
|||
window.location.protocol, |
|||
'//', |
|||
window.location.host, |
|||
window.location.pathname |
|||
].join('') |
|||
|
|||
var mapWithdrawLink = function (obj) { |
|||
obj._data = _.clone(obj) |
|||
obj.date = Quasar.utils.date.formatDate( |
|||
new Date(obj.time * 1000), |
|||
'YYYY-MM-DD HH:mm') |
|||
return obj |
|||
} |
|||
|
|||
new Vue({ |
|||
el: '#vue', |
|||
mixins: [windowMixin], |
|||
data: function () { |
|||
return { |
|||
format: 'po', |
|||
checker: null, |
|||
withdrawLinks: [], |
|||
withdrawLinksTable: { |
|||
columns: [ |
|||
{name: 'title', align: 'left', label: 'Title', field: 'title'}, |
|||
{name: 'id', align: 'left', label: 'ID', field: 'id'}, |
|||
{name: 'private_key', align: 'left', label: 'Key', field: 'private_key'}, |
|||
{ |
|||
name: 'amount', |
|||
align: 'right', |
|||
label: 'Amount withdrawn', |
|||
field: 'amount' |
|||
}, |
|||
], |
|||
pagination: { |
|||
rowsPerPage: 10 |
|||
} |
|||
}, |
|||
formDialog: { |
|||
show: false, |
|||
secondMultiplier: 'seconds', |
|||
secondMultiplierOptions: ['seconds', 'minutes', 'hours'], |
|||
data: { |
|||
is_unique: false |
|||
} |
|||
}, |
|||
simpleformDialog: { |
|||
show: false, |
|||
data: { |
|||
is_unique: false, |
|||
title: 'ATM link', |
|||
min_withdrawable: 0, |
|||
wait_time: 1 |
|||
} |
|||
}, |
|||
qrCodeDialog: { |
|||
show: false, |
|||
data: null |
|||
} |
|||
} |
|||
}, |
|||
computed: { |
|||
sortedWithdrawLinks: function () { |
|||
return this.withdrawLinks.sort(function (a, b) { |
|||
return b.uses_left - a.uses_left |
|||
}) |
|||
} |
|||
}, |
|||
methods: { |
|||
getWithdrawLinks: function () { |
|||
var self = this |
|||
|
|||
LNbits.api |
|||
.request( |
|||
'GET', |
|||
'/offlinelnurlw/api/v1/links?all_wallets', |
|||
this.g.user.wallets[0].inkey |
|||
) |
|||
.then(function (response) { |
|||
self.withdrawLinks = response.data.map(function (obj) { |
|||
return mapWithdrawLink(obj) |
|||
}) |
|||
}) |
|||
.catch(function (error) { |
|||
clearInterval(self.checker) |
|||
LNbits.utils.notifyApiError(error) |
|||
}) |
|||
}, |
|||
closeFormDialog: function () { |
|||
this.formDialog.data = { |
|||
is_unique: false |
|||
} |
|||
}, |
|||
simplecloseFormDialog: function () { |
|||
this.simpleformDialog.data = { |
|||
is_unique: false |
|||
} |
|||
}, |
|||
openQrCodeDialog: function (linkId) { |
|||
var link = _.findWhere(this.withdrawLinks, {id: linkId}) |
|||
|
|||
this.qrCodeDialog.data = _.clone(link) |
|||
console.log(this.qrCodeDialog.data) |
|||
this.qrCodeDialog.data.url = window.location.host |
|||
this.qrCodeDialog.show = true |
|||
}, |
|||
openUpdateDialog: function (linkId) { |
|||
var link = _.findWhere(this.withdrawLinks, {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') |
|||
|
|||
if (data.id) { |
|||
this.updateWithdrawLink(wallet, data) |
|||
} else { |
|||
this.createWithdrawLink(wallet, data) |
|||
} |
|||
}, |
|||
simplesendFormData: function () { |
|||
var wallet = _.findWhere(this.g.user.wallets, { |
|||
id: this.simpleformDialog.data.wallet |
|||
}) |
|||
var data = _.omit(this.simpleformDialog.data, 'wallet') |
|||
|
|||
if (data.id) { |
|||
this.updateWithdrawLink(wallet, data) |
|||
} else { |
|||
this.createWithdrawLink(wallet, data) |
|||
} |
|||
}, |
|||
updateWithdrawLink: function (wallet, data) { |
|||
var self = this |
|||
|
|||
LNbits.api |
|||
.request( |
|||
'PUT', |
|||
'/offlinelnurlw/api/v1/links/' + data.id, |
|||
wallet.adminkey, |
|||
_.pick( |
|||
data, |
|||
'title' |
|||
) |
|||
) |
|||
.then(function (response) { |
|||
self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { |
|||
return obj.id === data.id |
|||
}) |
|||
self.withdrawLinks.push(mapWithdrawLink(response.data)) |
|||
self.formDialog.show = false |
|||
}) |
|||
.catch(function (error) { |
|||
LNbits.utils.notifyApiError(error) |
|||
}) |
|||
}, |
|||
createWithdrawLink: function (wallet, data) { |
|||
var self = this |
|||
|
|||
LNbits.api |
|||
.request('POST', '/offlinelnurlw/api/v1/links', wallet.adminkey, data) |
|||
.then(function (response) { |
|||
self.withdrawLinks.push(mapWithdrawLink(response.data)) |
|||
self.formDialog.show = false |
|||
self.simpleformDialog.show = false |
|||
}) |
|||
.catch(function (error) { |
|||
LNbits.utils.notifyApiError(error) |
|||
}) |
|||
}, |
|||
deleteWithdrawLink: function (linkId) { |
|||
var self = this |
|||
var link = _.findWhere(this.withdrawLinks, {id: linkId}) |
|||
|
|||
LNbits.utils |
|||
.confirmDialog('Are you sure you want to delete this offline withdraw link?') |
|||
.onOk(function () { |
|||
LNbits.api |
|||
.request( |
|||
'DELETE', |
|||
'/offlinelnurlw/api/v1/links/' + linkId, |
|||
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey |
|||
) |
|||
.then(function (response) { |
|||
self.withdrawLinks = _.reject(self.withdrawLinks, 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 () { |
|||
var self = this |
|||
self.format = window.location.hostname |
|||
if (self.g.user.wallets.length) { |
|||
var getWithdrawLinks = self.getWithdrawLinks |
|||
getWithdrawLinks() |
|||
self.checker = setInterval(function () { |
|||
getWithdrawLinks() |
|||
}, 20000) |
|||
} |
|||
} |
|||
}) |
@ -0,0 +1,155 @@ |
|||
<q-expansion-item |
|||
group="extras" |
|||
icon="swap_vertical_circle" |
|||
label="API info" |
|||
:content-inset-level="0.5" |
|||
> |
|||
<q-expansion-item |
|||
group="api" |
|||
dense |
|||
expand-separator |
|||
label="List withdraw links" |
|||
> |
|||
<q-card> |
|||
<q-card-section> |
|||
<code><span class="text-blue">GET</span> /withdraw/api/v1/links</code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> |
|||
<code>{"X-Api-Key": <invoice_key>}</code><br /> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> |
|||
<h5 class="text-caption q-mt-sm q-mb-none"> |
|||
Returns 200 OK (application/json) |
|||
</h5> |
|||
<code>[<withdraw_link_object>, ...]</code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> |
|||
<code |
|||
>curl -X GET {{ request.url_root }}withdraw/api/v1/links -H |
|||
"X-Api-Key: {{ g.user.wallets[0].inkey }}" |
|||
</code> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
|||
<q-expansion-item |
|||
group="api" |
|||
dense |
|||
expand-separator |
|||
label="Get a withdraw link" |
|||
> |
|||
<q-card> |
|||
<q-card-section> |
|||
<code |
|||
><span class="text-blue">GET</span> |
|||
/withdraw/api/v1/links/<withdraw_id></code |
|||
> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> |
|||
<code>{"X-Api-Key": <invoice_key>}</code><br /> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> |
|||
<h5 class="text-caption q-mt-sm q-mb-none"> |
|||
Returns 201 CREATED (application/json) |
|||
</h5> |
|||
<code>{"lnurl": <string>}</code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> |
|||
<code |
|||
>curl -X GET {{ request.url_root |
|||
}}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ |
|||
g.user.wallets[0].inkey }}" |
|||
</code> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
|||
<q-expansion-item |
|||
group="api" |
|||
dense |
|||
expand-separator |
|||
label="Create a withdraw link" |
|||
> |
|||
<q-card> |
|||
<q-card-section> |
|||
<code><span class="text-green">POST</span> /withdraw/api/v1/links</code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> |
|||
<code>{"X-Api-Key": <admin_key>}</code><br /> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> |
|||
<code |
|||
>{"title": <string>, "min_withdrawable": <integer>, |
|||
"max_withdrawable": <integer>, "uses": <integer>, |
|||
"wait_time": <integer>, "is_unique": <boolean>}</code |
|||
> |
|||
<h5 class="text-caption q-mt-sm q-mb-none"> |
|||
Returns 201 CREATED (application/json) |
|||
</h5> |
|||
<code>{"lnurl": <string>}</code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> |
|||
<code |
|||
>curl -X POST {{ request.url_root }}withdraw/api/v1/links -d |
|||
'{"title": <string>, "min_withdrawable": <integer>, |
|||
"max_withdrawable": <integer>, "uses": <integer>, |
|||
"wait_time": <integer>, "is_unique": <boolean>}' -H |
|||
"Content-type: application/json" -H "X-Api-Key: {{ |
|||
g.user.wallets[0].adminkey }}" |
|||
</code> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
|||
<q-expansion-item |
|||
group="api" |
|||
dense |
|||
expand-separator |
|||
label="Update a withdraw link" |
|||
> |
|||
<q-card> |
|||
<q-card-section> |
|||
<code |
|||
><span class="text-green">PUT</span> |
|||
/withdraw/api/v1/links/<withdraw_id></code |
|||
> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> |
|||
<code>{"X-Api-Key": <admin_key>}</code><br /> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> |
|||
<code |
|||
>{"title": <string>, "min_withdrawable": <integer>, |
|||
"max_withdrawable": <integer>, "uses": <integer>, |
|||
"wait_time": <integer>, "is_unique": <boolean>}</code |
|||
> |
|||
<h5 class="text-caption q-mt-sm q-mb-none"> |
|||
Returns 200 OK (application/json) |
|||
</h5> |
|||
<code>{"lnurl": <string>}</code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> |
|||
<code |
|||
>curl -X PUT {{ request.url_root |
|||
}}withdraw/api/v1/links/<withdraw_id> -d '{"title": |
|||
<string>, "min_withdrawable": <integer>, |
|||
"max_withdrawable": <integer>, "uses": <integer>, |
|||
"wait_time": <integer>, "is_unique": <boolean>}' -H |
|||
"Content-type: application/json" -H "X-Api-Key: {{ |
|||
g.user.wallets[0].adminkey }}" |
|||
</code> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
|||
<q-expansion-item |
|||
group="api" |
|||
dense |
|||
expand-separator |
|||
label="Delete a withdraw link" |
|||
class="q-pb-md" |
|||
> |
|||
<q-card> |
|||
<q-card-section> |
|||
<code |
|||
><span class="text-pink">DELETE</span> |
|||
/withdraw/api/v1/links/<withdraw_id></code |
|||
> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> |
|||
<code>{"X-Api-Key": <admin_key>}</code><br /> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5> |
|||
<code></code> |
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> |
|||
<code |
|||
>curl -X DELETE {{ request.url_root |
|||
}}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ |
|||
g.user.wallets[0].adminkey }}" |
|||
</code> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
|||
</q-expansion-item> |
@ -0,0 +1,29 @@ |
|||
<q-expansion-item group="extras" icon="info" label="Powered by LNURL"> |
|||
<q-card> |
|||
<q-card-section> |
|||
<p> |
|||
<b>WARNING: LNURL must be used over https or TOR</b><br /> |
|||
LNURL is a range of lightning-network standards that allow us to use |
|||
lightning-network differently. An LNURL withdraw is the permission for |
|||
someone to pull a certain amount of funds from a lightning wallet. In |
|||
this extension time is also added - an amount can be withdraw over a |
|||
period of time. A typical use case for an LNURL withdraw is a faucet, |
|||
although it is a very powerful technology, with much further reaching |
|||
implications. For example, an LNURL withdraw could be minted to pay for |
|||
a subscription service. |
|||
</p> |
|||
<p> |
|||
Exploring LNURL and finding use cases, is really helping inform |
|||
lightning protocol development, rather than the protocol dictating how |
|||
lightning-network should be engaged with. |
|||
</p> |
|||
<small |
|||
>Check |
|||
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank" |
|||
>Awesome LNURL</a |
|||
> |
|||
for further information.</small |
|||
> |
|||
</q-card-section> |
|||
</q-card> |
|||
</q-expansion-item> |
@ -0,0 +1,160 @@ |
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context |
|||
%} {% block scripts %} {{ window_vars(user) }} |
|||
<script type="text/javascript" src="/offlinelnurlw/static/js/index.js"></script> |
|||
{% endblock %} {% block page %} |
|||
<div class="row q-col-gutter-md"> |
|||
<div class="col-12 col-md-7 q-gutter-y-md"> |
|||
<q-card> |
|||
<q-card-section> |
|||
<q-btn |
|||
unelevated |
|||
color="deep-purple" |
|||
@click="simpleformDialog.show = true" |
|||
>OfflineLNURLw link</q-btn |
|||
> |
|||
</q-card-section> |
|||
</q-card> |
|||
|
|||
<q-card> |
|||
<q-card-section> |
|||
<div class="row items-center no-wrap q-mb-md"> |
|||
<div class="col"> |
|||
<h5 class="text-subtitle1 q-my-none">OfflineLNURLw linkss <small v-model.trim="format"></small></h5> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<q-btn flat color="grey" @click="exportCSV">Export to CSVv</q-btn> |
|||
</div> |
|||
</div> |
|||
<q-table |
|||
dense |
|||
flat |
|||
:data="sortedWithdrawLinks" |
|||
row-key="id" |
|||
:columns="withdrawLinksTable.columns" |
|||
:pagination.sync="withdrawLinksTable.pagination" |
|||
> |
|||
{% raw %} |
|||
<template v-slot:header="props"> |
|||
<q-tr :props="props"> |
|||
<q-th auto-width></q-th> |
|||
<q-th v-for="col in props.cols" :key="col.name" :props="props"> |
|||
{{ col.label }} |
|||
</q-th> |
|||
<q-th auto-width></q-th> |
|||
</q-tr> |
|||
</template> |
|||
<template v-slot:body="props"> |
|||
<q-tr :props="props"> |
|||
<q-td auto-width> |
|||
<q-btn |
|||
unelevated |
|||
dense |
|||
size="xs" |
|||
icon="launch" |
|||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" |
|||
type="a" |
|||
:href="props.row.withdraw_url" |
|||
target="_blank" |
|||
></q-btn> |
|||
<q-btn |
|||
unelevated |
|||
dense |
|||
size="xs" |
|||
icon="visibility" |
|||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" |
|||
@click="openQrCodeDialog(props.row.id)" |
|||
></q-btn> |
|||
</q-td> |
|||
<q-td v-for="col in props.cols" :key="col.name" :props="props"> |
|||
{{ col.value }} |
|||
</q-td> |
|||
<q-td auto-width> |
|||
<q-btn |
|||
flat |
|||
dense |
|||
size="xs" |
|||
@click="openUpdateDialog(props.row.id)" |
|||
icon="edit" |
|||
color="light-blue" |
|||
></q-btn> |
|||
<q-btn |
|||
flat |
|||
dense |
|||
size="xs" |
|||
@click="deleteWithdrawLink(props.row.id)" |
|||
icon="cancel" |
|||
color="pink" |
|||
></q-btn> |
|||
</q-td> |
|||
</q-tr> |
|||
</template> |
|||
{% endraw %} |
|||
</q-table> |
|||
</q-card-section> |
|||
</q-card> |
|||
</div> |
|||
|
|||
<div class="col-12 col-md-5 q-gutter-y-md"> |
|||
<q-card> |
|||
<q-card-section> |
|||
<h6 class="text-subtitle1 q-my-none"> |
|||
LNbits OfflineLNURLw extension |
|||
</h6> |
|||
</q-card-section> |
|||
<q-card-section class="q-pa-none"> |
|||
<q-separator></q-separator> |
|||
<q-list> |
|||
{% include "offlinelnurlw/_api_docs.html" %} |
|||
<q-separator></q-separator> |
|||
{% include "offlinelnurlw/_lnurl.html" %} |
|||
</q-list> |
|||
</q-card-section> |
|||
</q-card> |
|||
</div> |
|||
|
|||
|
|||
|
|||
<q-dialog |
|||
v-model="simpleformDialog.show" |
|||
position="top" |
|||
@hide="simplecloseFormDialog" |
|||
> |
|||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> |
|||
<q-form @submit="simplesendFormData" class="q-gutter-md"> |
|||
<q-input |
|||
filled |
|||
dense |
|||
v-model.trim="simpleformDialog.data.title" |
|||
label="Name" |
|||
placeholder="Title" |
|||
></q-input> |
|||
|
|||
<q-select |
|||
filled |
|||
dense |
|||
emit-value |
|||
v-model="simpleformDialog.data.wallet" |
|||
:options="g.user.walletOptions" |
|||
label="Wallet *" |
|||
> |
|||
</q-select> |
|||
|
|||
<div class="row q-mt-lg"> |
|||
<q-btn |
|||
unelevated |
|||
color="deep-purple" |
|||
:disable=" |
|||
simpleformDialog.data.wallet == null || simpleformDialog.data.title == null" |
|||
type="submit" |
|||
>Create link</q-btn |
|||
> |
|||
<q-btn v-close-popup flat color="grey" class="q-ml-auto" |
|||
>Cancel</q-btn |
|||
> |
|||
</div> |
|||
</q-form> |
|||
</q-card> |
|||
</q-dialog> |
|||
|
|||
</div> |
|||
{% endblock %} |
@ -0,0 +1,13 @@ |
|||
from quart import g, abort, render_template |
|||
from http import HTTPStatus |
|||
|
|||
from lnbits.decorators import check_user_exists, validate_uuids |
|||
|
|||
from lnbits.extensions.offlinelnurlw import offlinelnurlw_ext |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/") |
|||
@validate_uuids(["usr"], required=True) |
|||
@check_user_exists() |
|||
async def index(): |
|||
return await render_template("offlinelnurlw/index.html", user=g.user) |
@ -0,0 +1,174 @@ |
|||
from datetime import datetime |
|||
from quart import g, jsonify, request |
|||
from http import HTTPStatus |
|||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl |
|||
import shortuuid # type: ignore |
|||
|
|||
|
|||
from lnbits.core.crud import get_user |
|||
from lnbits.core.services import pay_invoice |
|||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request |
|||
|
|||
from lnbits.extensions.offlinelnurlw import offlinelnurlw_ext |
|||
from .crud import ( |
|||
create_offlinelnurlw_link, |
|||
get_offlinelnurlw_link, |
|||
get_offlinelnurlw_links, |
|||
update_offlinelnurlw_link, |
|||
delete_offlinelnurlw_link, |
|||
) |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/links", methods=["GET"]) |
|||
@api_check_wallet_key("invoice") |
|||
async def api_links(): |
|||
wallet_ids = [g.wallet.id] |
|||
|
|||
if "all_wallets" in request.args: |
|||
wallet_ids = get_user(g.wallet.user).wallet_ids |
|||
try: |
|||
return ( |
|||
jsonify([{**link._asdict()} for link in get_offlinelnurlw_links(wallet_ids)]), |
|||
HTTPStatus.OK, |
|||
) |
|||
except LnurlInvalidUrl: |
|||
return ( |
|||
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}), |
|||
HTTPStatus.UPGRADE_REQUIRED, |
|||
) |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/links/<link_id>", methods=["GET"]) |
|||
@api_check_wallet_key("invoice") |
|||
async def api_link_retrieve(link_id): |
|||
link = get_offlinelnurlw_link(link_id, 0) |
|||
|
|||
if not link: |
|||
return jsonify({"message": "offlinelnurlw link does not exist."}), HTTPStatus.NOT_FOUND |
|||
|
|||
if link.wallet != g.wallet.id: |
|||
return jsonify({"message": "Not your offlinelnurlw link."}), HTTPStatus.FORBIDDEN |
|||
|
|||
return jsonify({**link._asdict()}), HTTPStatus.OK |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/links", methods=["POST"]) |
|||
@api_check_wallet_key("admin") |
|||
@api_validate_post_request( |
|||
schema={ |
|||
"title": {"type": "string", "empty": False, "required": True} |
|||
} |
|||
) |
|||
async def api_link_create(link_id=None): |
|||
|
|||
link = create_offlinelnurlw_link(wallet_id=g.wallet.id, title=g.data['title']) |
|||
return jsonify({**link._asdict()}), HTTPStatus.OK if link_id else HTTPStatus.CREATED |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/links/<link_id>", methods=["DELETE"]) |
|||
@api_check_wallet_key("admin") |
|||
async def api_link_delete(link_id): |
|||
link = get_offlinelnurlw_link(link_id) |
|||
|
|||
if not link: |
|||
return jsonify({"message": "offlineLNURLw link does not exist."}), HTTPStatus.NOT_FOUND |
|||
|
|||
if link.wallet != g.wallet.id: |
|||
return jsonify({"message": "Not your offlineLNURLw link."}), HTTPStatus.FORBIDDEN |
|||
|
|||
delete_offlinelnurlw_link(link_id) |
|||
|
|||
return "", HTTPStatus.NO_CONTENT |
|||
|
|||
|
|||
# FOR LNURLs WHICH ARE NOT UNIQUE |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/lnurl/<unique_hash>", methods=["GET"]) |
|||
async def api_lnurl_response(unique_hash): |
|||
link = get_offlinelnurlw_link_by_hash(unique_hash) |
|||
|
|||
if not link: |
|||
return jsonify({"status": "ERROR", "reason": "LNURL-offlinelnurlw not found."}), HTTPStatus.OK |
|||
|
|||
if link.is_unique == 1: |
|||
return jsonify({"status": "ERROR", "reason": "LNURL-offlinelnurlw not found."}), HTTPStatus.OK |
|||
usescsv = "" |
|||
for x in range(1, link.uses - link.used): |
|||
usescsv += "," + str(1) |
|||
usescsv = usescsv[1:] |
|||
link = update_offlinelnurlw_link(link.id, used=link.used + 1, usescsv=usescsv) |
|||
|
|||
return jsonify(link.lnurl_response.dict()), HTTPStatus.OK |
|||
|
|||
|
|||
# FOR LNURLs WHICH ARE UNIQUE |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/lnurl/<unique_hash>/<id_unique_hash>", methods=["GET"]) |
|||
async def api_lnurl_multi_response(unique_hash, id_unique_hash): |
|||
link = get_offlinelnurlw_link_by_hash(unique_hash) |
|||
|
|||
if not link: |
|||
return jsonify({"status": "ERROR", "reason": "LNURL-offlinelnurlw not found."}), HTTPStatus.OK |
|||
useslist = link.usescsv.split(",") |
|||
usescsv = "" |
|||
found = False |
|||
if link.is_unique == 0: |
|||
for x in range(link.uses - link.used): |
|||
usescsv += "," + str(1) |
|||
else: |
|||
for x in useslist: |
|||
tohash = link.id + link.unique_hash + str(x) |
|||
if id_unique_hash == shortuuid.uuid(name=tohash): |
|||
found = True |
|||
else: |
|||
usescsv += "," + x |
|||
if not found: |
|||
return jsonify({"status": "ERROR", "reason": "LNURL-offlinelnurlw not found."}), HTTPStatus.OK |
|||
|
|||
usescsv = usescsv[1:] |
|||
link = update_offlinelnurlw_link(link.id, used=link.used + 1, usescsv=usescsv) |
|||
return jsonify(link.lnurl_response.dict()), HTTPStatus.OK |
|||
|
|||
|
|||
@offlinelnurlw_ext.route("/api/v1/lnurl/cb/<unique_hash>", methods=["GET"]) |
|||
async def api_lnurl_callback(unique_hash): |
|||
link = get_offlinelnurlw_link_by_hash(unique_hash) |
|||
k1 = request.args.get("k1", type=str) |
|||
payment_request = request.args.get("pr", type=str) |
|||
now = int(datetime.now().timestamp()) |
|||
|
|||
if not link: |
|||
return jsonify({"status": "ERROR", "reason": "LNURL-offlinelnurlw not found."}), HTTPStatus.OK |
|||
|
|||
if link.is_spent: |
|||
return jsonify({"status": "ERROR", "reason": "offlinelnurlw is spent."}), HTTPStatus.OK |
|||
|
|||
if link.k1 != k1: |
|||
return jsonify({"status": "ERROR", "reason": "Bad request."}), HTTPStatus.OK |
|||
|
|||
if now < link.open_time: |
|||
return jsonify({"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}), HTTPStatus.OK |
|||
|
|||
try: |
|||
pay_invoice( |
|||
wallet_id=link.wallet, |
|||
payment_request=payment_request, |
|||
max_sat=link.max_offlinelnurlwable, |
|||
extra={"tag": "offlinelnurlw"}, |
|||
) |
|||
|
|||
changes = { |
|||
"open_time": link.wait_time + now, |
|||
} |
|||
|
|||
update_offlinelnurlw_link(link.id, **changes) |
|||
except ValueError as e: |
|||
return jsonify({"status": "ERROR", "reason": str(e)}), HTTPStatus.OK |
|||
except PermissionError: |
|||
return jsonify({"status": "ERROR", "reason": "offlinelnurlw link is empty."}), HTTPStatus.OK |
|||
except Exception as e: |
|||
return jsonify({"status": "ERROR", "reason": str(e)}), HTTPStatus.OK |
|||
|
|||
return jsonify({"status": "OK"}), HTTPStatus.OK |
Loading…
Reference in new issue