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