mirror of https://github.com/lukechilds/lnbits.git
fiatjaf
4 years ago
committed by
GitHub
29 changed files with 1136 additions and 60 deletions
@ -0,0 +1 @@ |
|||||
|
# LNURLp |
@ -0,0 +1,8 @@ |
|||||
|
from flask import Blueprint |
||||
|
|
||||
|
|
||||
|
lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates") |
||||
|
|
||||
|
|
||||
|
from .views_api import * # noqa |
||||
|
from .views import * # noqa |
@ -0,0 +1,10 @@ |
|||||
|
{ |
||||
|
"name": "LNURLp", |
||||
|
"short_description": "Make reusable LNURL pay links", |
||||
|
"icon": "receipt", |
||||
|
"contributors": [ |
||||
|
"arcbtc", |
||||
|
"eillarra", |
||||
|
"fiatjaf" |
||||
|
] |
||||
|
} |
@ -0,0 +1,74 @@ |
|||||
|
from typing import List, Optional, Union |
||||
|
|
||||
|
from lnbits.db import open_ext_db |
||||
|
|
||||
|
from .models import PayLink |
||||
|
|
||||
|
|
||||
|
def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink: |
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
with db.cursor() as c: |
||||
|
c.execute( |
||||
|
""" |
||||
|
INSERT INTO pay_links ( |
||||
|
wallet, |
||||
|
description, |
||||
|
amount, |
||||
|
served_meta, |
||||
|
served_pr |
||||
|
) |
||||
|
VALUES (?, ?, ?, 0, 0) |
||||
|
""", |
||||
|
(wallet_id, description, amount), |
||||
|
) |
||||
|
return get_pay_link(c.lastrowid) |
||||
|
|
||||
|
|
||||
|
def get_pay_link(link_id: str) -> Optional[PayLink]: |
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) |
||||
|
|
||||
|
return PayLink.from_row(row) if row else None |
||||
|
|
||||
|
|
||||
|
def get_pay_link_by_hash(unique_hash: str) -> Optional[PayLink]: |
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
row = db.fetchone("SELECT * FROM pay_links WHERE unique_hash = ?", (unique_hash,)) |
||||
|
|
||||
|
return PayLink.from_row(row) if row else None |
||||
|
|
||||
|
|
||||
|
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: |
||||
|
if isinstance(wallet_ids, str): |
||||
|
wallet_ids = [wallet_ids] |
||||
|
|
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
q = ",".join(["?"] * len(wallet_ids)) |
||||
|
rows = db.fetchall(f"SELECT * FROM pay_links WHERE wallet IN ({q})", (*wallet_ids,)) |
||||
|
|
||||
|
return [PayLink.from_row(row) for row in rows] |
||||
|
|
||||
|
|
||||
|
def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: |
||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) |
||||
|
|
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)) |
||||
|
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) |
||||
|
|
||||
|
return PayLink.from_row(row) if row else None |
||||
|
|
||||
|
|
||||
|
def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: |
||||
|
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) |
||||
|
|
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)) |
||||
|
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,)) |
||||
|
|
||||
|
return PayLink.from_row(row) if row else None |
||||
|
|
||||
|
|
||||
|
def delete_pay_link(link_id: str) -> None: |
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,)) |
@ -0,0 +1,24 @@ |
|||||
|
from lnbits.db import open_ext_db |
||||
|
|
||||
|
|
||||
|
def m001_initial(db): |
||||
|
""" |
||||
|
Initial pay table. |
||||
|
""" |
||||
|
db.execute( |
||||
|
""" |
||||
|
CREATE TABLE IF NOT EXISTS pay_links ( |
||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
wallet TEXT NOT NULL, |
||||
|
description TEXT NOT NULL, |
||||
|
amount INTEGER NOT NULL, |
||||
|
served_meta INTEGER NOT NULL, |
||||
|
served_pr INTEGER NOT NULL |
||||
|
); |
||||
|
""" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def migrate(): |
||||
|
with open_ext_db("lnurlp") as db: |
||||
|
m001_initial(db) |
@ -0,0 +1,32 @@ |
|||||
|
import json |
||||
|
from flask import url_for |
||||
|
from lnurl import Lnurl, encode as lnurl_encode |
||||
|
from lnurl.types import LnurlPayMetadata |
||||
|
from sqlite3 import Row |
||||
|
from typing import NamedTuple |
||||
|
|
||||
|
from lnbits.settings import FORCE_HTTPS |
||||
|
|
||||
|
|
||||
|
class PayLink(NamedTuple): |
||||
|
id: str |
||||
|
wallet: str |
||||
|
description: str |
||||
|
amount: int |
||||
|
served_meta: int |
||||
|
served_pr: int |
||||
|
|
||||
|
@classmethod |
||||
|
def from_row(cls, row: Row) -> "PayLink": |
||||
|
data = dict(row) |
||||
|
return cls(**data) |
||||
|
|
||||
|
@property |
||||
|
def lnurl(self) -> Lnurl: |
||||
|
scheme = "https" if FORCE_HTTPS else None |
||||
|
url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True, _scheme=scheme) |
||||
|
return lnurl_encode(url) |
||||
|
|
||||
|
@property |
||||
|
def lnurlpay_metadata(self) -> LnurlPayMetadata: |
||||
|
return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) |
@ -0,0 +1,130 @@ |
|||||
|
<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 pay links"> |
||||
|
<q-card> |
||||
|
<q-card-section> |
||||
|
<code><span class="text-blue">GET</span> /pay/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>[<pay_link_object>, ...]</code> |
||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> |
||||
|
<code |
||||
|
>curl -X GET {{ request.url_root }}pay/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 pay link"> |
||||
|
<q-card> |
||||
|
<q-card-section> |
||||
|
<code |
||||
|
><span class="text-blue">GET</span> |
||||
|
/pay/api/v1/links/<pay_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 }}pay/api/v1/links/<pay_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 pay link" |
||||
|
> |
||||
|
<q-card> |
||||
|
<q-card-section> |
||||
|
<code><span class="text-green">POST</span> /pay/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>{"description": <string> "amount": <integer>}</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 }}pay/api/v1/links -d |
||||
|
'{"description": <string>, "amount": <integer>}' -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 pay link" |
||||
|
> |
||||
|
<q-card> |
||||
|
<q-card-section> |
||||
|
<code |
||||
|
><span class="text-green">PUT</span> |
||||
|
/pay/api/v1/links/<pay_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>{"description": <string>, "amount": <integer>}</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 }}pay/api/v1/links/<pay_id> -d |
||||
|
'{"description": <string>, "amount": <integer>}' -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 pay link" |
||||
|
class="q-pb-md" |
||||
|
> |
||||
|
<q-card> |
||||
|
<q-card-section> |
||||
|
<code |
||||
|
><span class="text-pink">DELETE</span> |
||||
|
/pay/api/v1/links/<pay_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 }}pay/api/v1/links/<pay_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,28 @@ |
|||||
|
<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-pay is a link that wallets use |
||||
|
to fetch an invoice from a server on-demand. The link or QR code is |
||||
|
fixed, but each time it is read by a compatible wallet a new QR code is |
||||
|
issued by the service. It can be used to activate machines without them |
||||
|
having to maintain an electronic screen to generate and show invoices |
||||
|
locally, or to sell any predefined good or service automatically. |
||||
|
</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,54 @@ |
|||||
|
{% extends "public.html" %} {% block page %} |
||||
|
<div class="row q-col-gutter-md justify-center"> |
||||
|
<div class="col-12 col-sm-6 col-md-5 col-lg-4"> |
||||
|
<q-card class="q-pa-lg"> |
||||
|
<q-card-section class="q-pa-none"> |
||||
|
<div class="text-center"> |
||||
|
<a href="lightning:{{ link.lnurl }}"> |
||||
|
<q-responsive :ratio="1" class="q-mx-md"> |
||||
|
<qrcode |
||||
|
value="{{ link.lnurl }}" |
||||
|
:options="{width: 800}" |
||||
|
class="rounded-borders" |
||||
|
></qrcode> |
||||
|
</q-responsive> |
||||
|
</a> |
||||
|
</div> |
||||
|
<div class="row q-mt-lg"> |
||||
|
<q-btn outline color="grey" @click="copyText('{{ link.lnurl }}')" |
||||
|
>Copy LNURL</q-btn |
||||
|
> |
||||
|
</div> |
||||
|
</q-card-section> |
||||
|
</q-card> |
||||
|
</div> |
||||
|
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md"> |
||||
|
<q-card> |
||||
|
<q-card-section> |
||||
|
<h6 class="text-subtitle1 q-mb-sm q-mt-none"> |
||||
|
LNbits LNURL-pay link |
||||
|
</h6> |
||||
|
<p class="q-my-none"> |
||||
|
Use a LNURL compatible bitcoin wallet to claim the sats. |
||||
|
</p> |
||||
|
</q-card-section> |
||||
|
<q-card-section class="q-pa-none"> |
||||
|
<q-separator></q-separator> |
||||
|
<q-list> |
||||
|
{% include "lnurlp/_lnurl.html" %} |
||||
|
</q-list> |
||||
|
</q-card-section> |
||||
|
</q-card> |
||||
|
</div> |
||||
|
</div> |
||||
|
{% endblock %} {% block scripts %} |
||||
|
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script> |
||||
|
<script> |
||||
|
Vue.component(VueQrcode.name, VueQrcode) |
||||
|
|
||||
|
new Vue({ |
||||
|
el: '#vue', |
||||
|
mixins: [windowMixin] |
||||
|
}) |
||||
|
</script> |
||||
|
{% endblock %} |
@ -0,0 +1,401 @@ |
|||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context |
||||
|
%} {% 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="formDialog.show = true" |
||||
|
>New pay 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">Pay links</h5> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn> |
||||
|
</div> |
||||
|
</div> |
||||
|
<q-table |
||||
|
dense |
||||
|
flat |
||||
|
:data="payLinks" |
||||
|
row-key="id" |
||||
|
:columns="payLinksTable.columns" |
||||
|
:pagination.sync="payLinksTable.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.pay_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="deletePayLink(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 LNURL-pay extension |
||||
|
</h6> |
||||
|
</q-card-section> |
||||
|
<q-card-section class="q-pa-none"> |
||||
|
<q-separator></q-separator> |
||||
|
<q-list> |
||||
|
{% include "lnurlp/_api_docs.html" %} |
||||
|
<q-separator></q-separator> |
||||
|
{% include "lnurlp/_lnurl.html" %} |
||||
|
</q-list> |
||||
|
</q-card-section> |
||||
|
</q-card> |
||||
|
</div> |
||||
|
|
||||
|
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog"> |
||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> |
||||
|
<q-form @submit="sendFormData" class="q-gutter-md"> |
||||
|
<q-select |
||||
|
filled |
||||
|
dense |
||||
|
emit-value |
||||
|
v-model="formDialog.data.wallet" |
||||
|
:options="g.user.walletOptions" |
||||
|
label="Wallet *" |
||||
|
> |
||||
|
</q-select> |
||||
|
<q-input |
||||
|
filled |
||||
|
dense |
||||
|
v-model.trim="formDialog.data.description" |
||||
|
type="text" |
||||
|
label="Item description *" |
||||
|
></q-input> |
||||
|
<q-input |
||||
|
filled |
||||
|
dense |
||||
|
v-model.number="formDialog.data.amount" |
||||
|
type="number" |
||||
|
label="Amount (sat) *" |
||||
|
></q-input> |
||||
|
<div class="row q-mt-lg"> |
||||
|
<q-btn |
||||
|
v-if="formDialog.data.id" |
||||
|
unelevated |
||||
|
color="deep-purple" |
||||
|
type="submit" |
||||
|
>Update pay link</q-btn |
||||
|
> |
||||
|
<q-btn |
||||
|
v-else |
||||
|
unelevated |
||||
|
color="deep-purple" |
||||
|
:disable=" |
||||
|
formDialog.data.wallet == null || |
||||
|
formDialog.data.description == null || |
||||
|
( |
||||
|
formDialog.data.amount == null || |
||||
|
formDialog.data.amount < 1 |
||||
|
)" |
||||
|
type="submit" |
||||
|
>Create pay 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> |
||||
|
|
||||
|
<q-dialog v-model="qrCodeDialog.show" position="top"> |
||||
|
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card"> |
||||
|
{% raw %} |
||||
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md"> |
||||
|
<qrcode |
||||
|
:value="qrCodeDialog.data.lnurl" |
||||
|
:options="{width: 800}" |
||||
|
class="rounded-borders" |
||||
|
></qrcode> |
||||
|
</q-responsive> |
||||
|
<p style="word-break: break-all;"> |
||||
|
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> |
||||
|
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br /> |
||||
|
</p> |
||||
|
{% endraw %} |
||||
|
<div class="row q-mt-lg q-gutter-sm"> |
||||
|
<q-btn |
||||
|
outline |
||||
|
color="grey" |
||||
|
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')" |
||||
|
class="q-ml-sm" |
||||
|
>Copy LNURL</q-btn |
||||
|
> |
||||
|
<q-btn |
||||
|
outline |
||||
|
color="grey" |
||||
|
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')" |
||||
|
>Shareable link</q-btn |
||||
|
> |
||||
|
<q-btn |
||||
|
v-if="!qrCodeDialog.data.is_unique" |
||||
|
outline |
||||
|
color="grey" |
||||
|
icon="print" |
||||
|
type="a" |
||||
|
:href="qrCodeDialog.data.print_url" |
||||
|
target="_blank" |
||||
|
></q-btn> |
||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> |
||||
|
</div> |
||||
|
</q-card> |
||||
|
</q-dialog> |
||||
|
</div> |
||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }} |
||||
|
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script> |
||||
|
<script> |
||||
|
Vue.component(VueQrcode.name, VueQrcode) |
||||
|
|
||||
|
var locationPath = [ |
||||
|
window.location.protocol, |
||||
|
'//', |
||||
|
window.location.hostname, |
||||
|
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' |
||||
|
} |
||||
|
], |
||||
|
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 |
||||
|
|
||||
|
LNbits.api |
||||
|
.request( |
||||
|
'PUT', |
||||
|
'/lnurlp/api/v1/links/' + data.id, |
||||
|
wallet.adminkey, |
||||
|
_.pick(data, 'description', 'amount') |
||||
|
) |
||||
|
.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 %} |
@ -0,0 +1,28 @@ |
|||||
|
{% extends "print.html" %} {% block page %} |
||||
|
<div class="row justify-center"> |
||||
|
<div class="qr"> |
||||
|
<qrcode value="{{ link.lnurl }}" :options="{width}"></qrcode> |
||||
|
</div> |
||||
|
</div> |
||||
|
{% endblock %} {% block styles %} |
||||
|
<style> |
||||
|
.qr { |
||||
|
margin: auto; |
||||
|
} |
||||
|
</style> |
||||
|
{% endblock %} {% block scripts %} |
||||
|
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script> |
||||
|
<script> |
||||
|
Vue.component(VueQrcode.name, VueQrcode) |
||||
|
|
||||
|
new Vue({ |
||||
|
el: '#vue', |
||||
|
created: function () { |
||||
|
window.print() |
||||
|
}, |
||||
|
data: function () { |
||||
|
return {width: window.innerWidth * 0.5} |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
{% endblock %} |
@ -0,0 +1,28 @@ |
|||||
|
from flask import g, abort, render_template |
||||
|
from http import HTTPStatus |
||||
|
|
||||
|
from lnbits.decorators import check_user_exists, validate_uuids |
||||
|
|
||||
|
from lnbits.extensions.lnurlp import lnurlp_ext |
||||
|
from .crud import get_pay_link |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/") |
||||
|
@validate_uuids(["usr"], required=True) |
||||
|
@check_user_exists() |
||||
|
def index(): |
||||
|
return render_template("lnurlp/index.html", user=g.user) |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/<link_id>") |
||||
|
def display(link_id): |
||||
|
link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") |
||||
|
|
||||
|
return render_template("lnurlp/display.html", link=link) |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/print/<link_id>") |
||||
|
def print_qr(link_id): |
||||
|
link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") |
||||
|
|
||||
|
return render_template("lnurlp/print_qr.html", link=link) |
@ -0,0 +1,129 @@ |
|||||
|
import hashlib |
||||
|
from flask import g, jsonify, request, url_for |
||||
|
from http import HTTPStatus |
||||
|
from lnurl import LnurlPayResponse, LnurlPayActionResponse |
||||
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl |
||||
|
|
||||
|
from lnbits.core.crud import get_user |
||||
|
from lnbits.core.services import create_invoice |
||||
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request |
||||
|
from lnbits.settings import FORCE_HTTPS |
||||
|
|
||||
|
from lnbits.extensions.lnurlp import lnurlp_ext |
||||
|
from .crud import ( |
||||
|
create_pay_link, |
||||
|
get_pay_link, |
||||
|
get_pay_links, |
||||
|
update_pay_link, |
||||
|
increment_pay_link, |
||||
|
delete_pay_link, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/api/v1/links", methods=["GET"]) |
||||
|
@api_check_wallet_key("invoice") |
||||
|
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(), **{"lnurl": link.lnurl}} for link in get_pay_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, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["GET"]) |
||||
|
@api_check_wallet_key("invoice") |
||||
|
def api_link_retrieve(link_id): |
||||
|
link = get_pay_link(link_id) |
||||
|
|
||||
|
if not link: |
||||
|
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND |
||||
|
|
||||
|
if link.wallet != g.wallet.id: |
||||
|
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN |
||||
|
|
||||
|
return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/api/v1/links", methods=["POST"]) |
||||
|
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["PUT"]) |
||||
|
@api_check_wallet_key("invoice") |
||||
|
@api_validate_post_request( |
||||
|
schema={ |
||||
|
"description": {"type": "string", "empty": False, "required": True}, |
||||
|
"amount": {"type": "integer", "min": 1, "required": True}, |
||||
|
} |
||||
|
) |
||||
|
def api_link_create_or_update(link_id=None): |
||||
|
if link_id: |
||||
|
link = get_pay_link(link_id) |
||||
|
|
||||
|
if not link: |
||||
|
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND |
||||
|
|
||||
|
if link.wallet != g.wallet.id: |
||||
|
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN |
||||
|
|
||||
|
link = update_pay_link(link_id, **g.data) |
||||
|
else: |
||||
|
link = create_pay_link(wallet_id=g.wallet.id, **g.data) |
||||
|
|
||||
|
return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["DELETE"]) |
||||
|
@api_check_wallet_key("invoice") |
||||
|
def api_link_delete(link_id): |
||||
|
link = get_pay_link(link_id) |
||||
|
|
||||
|
if not link: |
||||
|
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND |
||||
|
|
||||
|
if link.wallet != g.wallet.id: |
||||
|
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN |
||||
|
|
||||
|
delete_pay_link(link_id) |
||||
|
|
||||
|
return "", HTTPStatus.NO_CONTENT |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"]) |
||||
|
def api_lnurl_response(link_id): |
||||
|
link = increment_pay_link(link_id, served_meta=1) |
||||
|
if not link: |
||||
|
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK |
||||
|
|
||||
|
scheme = "https" if FORCE_HTTPS else None |
||||
|
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True, _scheme=scheme) |
||||
|
|
||||
|
resp = LnurlPayResponse( |
||||
|
callback=url, min_sendable=link.amount * 1000, max_sendable=link.amount * 1000, metadata=link.lnurlpay_metadata, |
||||
|
) |
||||
|
|
||||
|
return jsonify(resp.dict()), HTTPStatus.OK |
||||
|
|
||||
|
|
||||
|
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"]) |
||||
|
def api_lnurl_callback(link_id): |
||||
|
link = increment_pay_link(link_id, served_pr=1) |
||||
|
if not link: |
||||
|
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK |
||||
|
|
||||
|
_, payment_request = create_invoice( |
||||
|
wallet_id=link.wallet, |
||||
|
amount=link.amount, |
||||
|
memo=link.description, |
||||
|
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(), |
||||
|
) |
||||
|
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[]) |
||||
|
|
||||
|
return jsonify(resp.dict()), HTTPStatus.OK |
@ -0,0 +1,86 @@ |
|||||
|
import random |
||||
|
import requests |
||||
|
from os import getenv |
||||
|
|
||||
|
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet |
||||
|
|
||||
|
|
||||
|
class SparkError(Exception): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class UnknownError(Exception): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class SparkWallet(Wallet): |
||||
|
def __init__(self): |
||||
|
self.url = getenv("SPARK_URL") |
||||
|
self.token = getenv("SPARK_TOKEN") |
||||
|
|
||||
|
def __getattr__(self, key): |
||||
|
def call(*args, **kwargs): |
||||
|
if args and kwargs: |
||||
|
raise TypeError(f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}") |
||||
|
elif args: |
||||
|
params = args |
||||
|
elif kwargs: |
||||
|
params = kwargs |
||||
|
|
||||
|
r = requests.post(self.url, headers={"X-Access": self.token}, json={"method": key, "params": params}) |
||||
|
try: |
||||
|
data = r.json() |
||||
|
except: |
||||
|
raise UnknownError(r.text) |
||||
|
if not r.ok: |
||||
|
raise SparkError(data["message"]) |
||||
|
return data |
||||
|
|
||||
|
return call |
||||
|
|
||||
|
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse: |
||||
|
label = "lbs{}".format(random.random()) |
||||
|
checking_id = label |
||||
|
|
||||
|
try: |
||||
|
if description_hash: |
||||
|
r = self.invoicewithdescriptionhash( |
||||
|
msatoshi=amount * 1000, label=label, description_hash=description_hash.hex(), |
||||
|
) |
||||
|
else: |
||||
|
r = self.invoice(msatoshi=amount * 1000, label=label, description=memo, exposeprivatechannels=True) |
||||
|
ok, payment_request, error_message = True, r["bolt11"], "" |
||||
|
except (SparkError, UnknownError) as e: |
||||
|
ok, payment_request, error_message = False, None, str(e) |
||||
|
|
||||
|
return InvoiceResponse(ok, checking_id, payment_request, error_message) |
||||
|
|
||||
|
def pay_invoice(self, bolt11: str) -> PaymentResponse: |
||||
|
try: |
||||
|
r = self.pay(bolt11) |
||||
|
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None |
||||
|
except (SparkError, UnknownError) as e: |
||||
|
ok, checking_id, fee_msat, error_message = False, None, None, str(e) |
||||
|
|
||||
|
return PaymentResponse(ok, checking_id, fee_msat, error_message) |
||||
|
|
||||
|
def get_invoice_status(self, checking_id: str) -> PaymentStatus: |
||||
|
r = self.listinvoices(label=checking_id) |
||||
|
if not r or not r.get("invoices"): |
||||
|
return PaymentStatus(None) |
||||
|
if r["invoices"][0]["status"] == "unpaid": |
||||
|
return PaymentStatus(False) |
||||
|
return PaymentStatus(True) |
||||
|
|
||||
|
def get_payment_status(self, checking_id: str) -> PaymentStatus: |
||||
|
r = self.listpays(payment_hash=checking_id) |
||||
|
if not r["pays"]: |
||||
|
return PaymentStatus(False) |
||||
|
if r["pays"][0]["payment_hash"] == checking_id: |
||||
|
status = r["pays"][0]["status"] |
||||
|
if status == "complete": |
||||
|
return PaymentStatus(True) |
||||
|
elif status == "failed": |
||||
|
return PaymentStatus(False) |
||||
|
return PaymentStatus(None) |
||||
|
raise KeyError("supplied an invalid checking_id") |
Loading…
Reference in new issue