Add files via upload
This commit is contained in:
commit
ad9fd4e4ad
17 changed files with 1645 additions and 0 deletions
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# LNURLp
|
||||||
|
|
||||||
|
## Create a static QR code people can use to pay over Lightning Network
|
||||||
|
|
||||||
|
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 invoice is issued by the service and sent to the wallet.
|
||||||
|
|
||||||
|
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Create an LNURLp (New Pay link)\
|
||||||
|

|
||||||
|
|
||||||
|
- select your wallets
|
||||||
|
- make a small description
|
||||||
|
- enter amount
|
||||||
|
- if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount
|
||||||
|
- you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp
|
||||||
|
- You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post)
|
||||||
|
- Webhook URL allows to call an URL when the LNURLp is paid
|
||||||
|
- Success mesage, will send a message back to the user after a successful payment, for example a thank you note
|
||||||
|
- Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link
|
||||||
|
|
||||||
|
2. Use the shareable link or view the LNURLp you just created\
|
||||||
|

|
||||||
|
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
|
||||||
|

|
||||||
35
__init__.py
Normal file
35
__init__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_lnurlp")
|
||||||
|
|
||||||
|
lnurlp_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/lnurlp/static",
|
||||||
|
"app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]),
|
||||||
|
"name": "lnurlp_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
|
||||||
|
|
||||||
|
|
||||||
|
def lnurlp_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/lnurlp/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .lnurl import * # noqa: F401,F403
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
|
from .views import * # noqa: F401,F403
|
||||||
|
from .views_api import * # noqa: F401,F403
|
||||||
|
|
||||||
|
|
||||||
|
def lnurlp_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
10
config.json
Normal file
10
config.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "LNURLp",
|
||||||
|
"short_description": "Make reusable LNURL pay links",
|
||||||
|
"tile": "/lnurlp/static/image/lnurl-pay.png",
|
||||||
|
"contributors": [
|
||||||
|
"arcbtc",
|
||||||
|
"eillarra",
|
||||||
|
"fiatjaf"
|
||||||
|
]
|
||||||
|
}
|
||||||
95
crud.py
Normal file
95
crud.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import CreatePayLinkData, PayLink
|
||||||
|
|
||||||
|
|
||||||
|
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
|
link_id = urlsafe_short_hash()[:6]
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lnurlp.pay_links (
|
||||||
|
id,
|
||||||
|
wallet,
|
||||||
|
description,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
served_meta,
|
||||||
|
served_pr,
|
||||||
|
webhook_url,
|
||||||
|
webhook_headers,
|
||||||
|
webhook_body,
|
||||||
|
success_text,
|
||||||
|
success_url,
|
||||||
|
comment_chars,
|
||||||
|
currency,
|
||||||
|
fiat_base_multiplier
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
link_id,
|
||||||
|
wallet_id,
|
||||||
|
data.description,
|
||||||
|
data.min,
|
||||||
|
data.max,
|
||||||
|
data.webhook_url,
|
||||||
|
data.webhook_headers,
|
||||||
|
data.webhook_body,
|
||||||
|
data.success_text,
|
||||||
|
data.success_url,
|
||||||
|
data.comment_chars,
|
||||||
|
data.currency,
|
||||||
|
data.fiat_base_multiplier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result
|
||||||
|
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
assert link, "Newly created link couldn't be retrieved"
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pay_link(link_id: str) -> Optional[PayLink]:
|
||||||
|
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
||||||
|
return PayLink.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q})
|
||||||
|
ORDER BY Id
|
||||||
|
""",
|
||||||
|
(*wallet_ids,),
|
||||||
|
)
|
||||||
|
return [PayLink.from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||||
|
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
||||||
|
return PayLink.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||||
|
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
||||||
|
)
|
||||||
|
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
||||||
|
return PayLink.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_pay_link(link_id: int) -> None:
|
||||||
|
await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
||||||
106
lnurl.py
Normal file
106
lnurl.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core.services import create_invoice
|
||||||
|
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
||||||
|
|
||||||
|
from . import lnurlp_ext
|
||||||
|
from .crud import increment_pay_link
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get(
|
||||||
|
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL)
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="lnurlp.api_lnurl_response.deprecated",
|
||||||
|
)
|
||||||
|
@lnurlp_ext.get(
|
||||||
|
"/{link_id}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="lnurlp.api_lnurl_response",
|
||||||
|
)
|
||||||
|
async def api_lnurl_response(request: Request, link_id):
|
||||||
|
link = await increment_pay_link(link_id, served_meta=1)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
|
|
||||||
|
resp = LnurlPayResponse(
|
||||||
|
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
|
||||||
|
min_sendable=round(link.min * rate) * 1000,
|
||||||
|
max_sendable=round(link.max * rate) * 1000,
|
||||||
|
metadata=link.lnurlpay_metadata,
|
||||||
|
)
|
||||||
|
params = resp.dict()
|
||||||
|
|
||||||
|
if link.comment_chars > 0:
|
||||||
|
params["commentAllowed"] = link.comment_chars
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get(
|
||||||
|
"/api/v1/lnurl/cb/{link_id}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="lnurlp.api_lnurl_callback",
|
||||||
|
)
|
||||||
|
async def api_lnurl_callback(request: Request, link_id):
|
||||||
|
link = await increment_pay_link(link_id, served_pr=1)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
|
)
|
||||||
|
min, max = link.min, link.max
|
||||||
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
|
if link.currency:
|
||||||
|
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||||
|
min = rate * 995 * link.min
|
||||||
|
max = rate * 1010 * link.max
|
||||||
|
else:
|
||||||
|
min = link.min * 1000
|
||||||
|
max = link.max * 1000
|
||||||
|
|
||||||
|
amount_received = int(request.query_params.get("amount") or 0)
|
||||||
|
if amount_received < min:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
||||||
|
).dict()
|
||||||
|
|
||||||
|
elif amount_received > max:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=f"Amount {amount_received} is greater than maximum {max}."
|
||||||
|
).dict()
|
||||||
|
|
||||||
|
comment = request.query_params.get("comment")
|
||||||
|
if len(comment or "") > link.comment_chars:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
|
||||||
|
).dict()
|
||||||
|
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=link.wallet,
|
||||||
|
amount=int(amount_received / 1000),
|
||||||
|
memo=link.description,
|
||||||
|
unhashed_description=link.lnurlpay_metadata.encode(),
|
||||||
|
extra={
|
||||||
|
"tag": "lnurlp",
|
||||||
|
"link": link.id,
|
||||||
|
"comment": comment,
|
||||||
|
"extra": request.query_params.get("amount"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
success_action = link.success_action(payment_hash)
|
||||||
|
if success_action:
|
||||||
|
resp = LnurlPayActionResponse(
|
||||||
|
pr=payment_request, success_action=success_action, routes=[]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resp = LnurlPayActionResponse(pr=payment_request, routes=[])
|
||||||
|
|
||||||
|
return resp.dict()
|
||||||
148
migrations.py
Normal file
148
migrations.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial pay table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE lnurlp.pay_links (
|
||||||
|
id {db.serial_primary_key},
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount {db.big_int} NOT NULL,
|
||||||
|
served_meta INTEGER NOT NULL,
|
||||||
|
served_pr INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_webhooks_and_success_actions(db):
|
||||||
|
"""
|
||||||
|
Webhooks and success actions.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;")
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;")
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE lnurlp.invoices (
|
||||||
|
pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
|
||||||
|
payment_hash TEXT NOT NULL,
|
||||||
|
webhook_sent INT, -- null means not sent, otherwise store status
|
||||||
|
expiry INT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m003_min_max_comment_fiat(db):
|
||||||
|
"""
|
||||||
|
Support for min/max amounts, comments and fiat prices that get
|
||||||
|
converted automatically to satoshis based on some API.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE lnurlp.pay_links ADD COLUMN currency TEXT;"
|
||||||
|
) # null = satoshis
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
|
||||||
|
)
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;")
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
|
||||||
|
await db.execute("UPDATE lnurlp.pay_links SET max = min;")
|
||||||
|
await db.execute("DROP TABLE lnurlp.invoices")
|
||||||
|
|
||||||
|
|
||||||
|
async def m004_fiat_base_multiplier(db):
|
||||||
|
"""
|
||||||
|
Store the multiplier for fiat prices. We store the price in cents and
|
||||||
|
remember to multiply by 100 when we use it to convert to Dollars.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m005_webhook_headers_and_body(db):
|
||||||
|
"""
|
||||||
|
Add headers and body to webhooks
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")
|
||||||
|
|
||||||
|
|
||||||
|
async def m006_redux(db):
|
||||||
|
"""
|
||||||
|
Migrate ID column type to string for UUIDs and migrate existing data
|
||||||
|
"""
|
||||||
|
# we can simply change the column type for postgres
|
||||||
|
if db.type != "SQLITE":
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ALTER COLUMN id TYPE TEXT;")
|
||||||
|
else:
|
||||||
|
# but we have to do this for sqlite
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
|
||||||
|
await db.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE lnurlp.pay_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
min {db.big_int} NOT NULL,
|
||||||
|
max {db.big_int},
|
||||||
|
currency TEXT,
|
||||||
|
fiat_base_multiplier INTEGER DEFAULT 1,
|
||||||
|
served_meta INTEGER NOT NULL,
|
||||||
|
served_pr INTEGER NOT NULL,
|
||||||
|
webhook_url TEXT,
|
||||||
|
success_text TEXT,
|
||||||
|
success_url TEXT,
|
||||||
|
comment_chars INTEGER DEFAULT 0,
|
||||||
|
webhook_headers TEXT,
|
||||||
|
webhook_body TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in [
|
||||||
|
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
|
||||||
|
]:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lnurlp.pay_links (
|
||||||
|
id,
|
||||||
|
wallet,
|
||||||
|
description,
|
||||||
|
min,
|
||||||
|
served_meta,
|
||||||
|
served_pr,
|
||||||
|
webhook_url,
|
||||||
|
success_text,
|
||||||
|
success_url,
|
||||||
|
currency,
|
||||||
|
comment_chars,
|
||||||
|
max,
|
||||||
|
fiat_base_multiplier,
|
||||||
|
webhook_headers,
|
||||||
|
webhook_body
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row[0],
|
||||||
|
row[1],
|
||||||
|
row[2],
|
||||||
|
row[3],
|
||||||
|
row[4],
|
||||||
|
row[5],
|
||||||
|
row[6],
|
||||||
|
row[7],
|
||||||
|
row[8],
|
||||||
|
row[9],
|
||||||
|
row[10],
|
||||||
|
row[11],
|
||||||
|
row[12],
|
||||||
|
row[13],
|
||||||
|
row[14],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute("DROP TABLE lnurlp.pay_links_old")
|
||||||
75
models.py
Normal file
75
models.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import json
|
||||||
|
from sqlite3 import Row
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from urllib.parse import ParseResult, urlparse, urlunparse
|
||||||
|
|
||||||
|
from fastapi.param_functions import Query
|
||||||
|
from lnurl.types import LnurlPayMetadata
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from lnbits.lnurl import encode as lnurl_encode
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePayLinkData(BaseModel):
|
||||||
|
description: str
|
||||||
|
min: float = Query(1, ge=0.01)
|
||||||
|
max: float = Query(1, ge=0.01)
|
||||||
|
currency: str = Query(None)
|
||||||
|
comment_chars: int = Query(0, ge=0, lt=800)
|
||||||
|
webhook_url: str = Query(None)
|
||||||
|
webhook_headers: str = Query(None)
|
||||||
|
webhook_body: str = Query(None)
|
||||||
|
success_text: str = Query(None)
|
||||||
|
success_url: str = Query(None)
|
||||||
|
fiat_base_multiplier: int = Query(100, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PayLink(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
description: str
|
||||||
|
min: float
|
||||||
|
served_meta: int
|
||||||
|
served_pr: int
|
||||||
|
webhook_url: Optional[str]
|
||||||
|
webhook_headers: Optional[str]
|
||||||
|
webhook_body: Optional[str]
|
||||||
|
success_text: Optional[str]
|
||||||
|
success_url: Optional[str]
|
||||||
|
currency: Optional[str]
|
||||||
|
comment_chars: int
|
||||||
|
max: float
|
||||||
|
fiat_base_multiplier: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "PayLink":
|
||||||
|
data = dict(row)
|
||||||
|
if data["currency"] and data["fiat_base_multiplier"]:
|
||||||
|
data["min"] /= data["fiat_base_multiplier"]
|
||||||
|
data["max"] /= data["fiat_base_multiplier"]
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
|
def lnurl(self, req: Request) -> str:
|
||||||
|
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
|
||||||
|
return lnurl_encode(url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
|
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
|
||||||
|
|
||||||
|
def success_action(self, payment_hash: str) -> Optional[Dict]:
|
||||||
|
if self.success_url:
|
||||||
|
url: ParseResult = urlparse(self.success_url)
|
||||||
|
#qs = parse_qs(url.query)
|
||||||
|
#setattr(qs, "payment_hash", payment_hash)
|
||||||
|
#url = url._replace(query=urlencode(qs, doseq=True))
|
||||||
|
return {
|
||||||
|
"tag": "url",
|
||||||
|
"description": self.success_text or "~",
|
||||||
|
"url": urlunparse(url),
|
||||||
|
}
|
||||||
|
elif self.success_text:
|
||||||
|
return {"tag": "message", "message": self.success_text}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
BIN
static/image/lnurl-pay.png
Normal file
BIN
static/image/lnurl-pay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
264
static/js/index.js
Normal file
264
static/js/index.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||||
|
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
var locationPath = [
|
||||||
|
window.location.protocol,
|
||||||
|
'//',
|
||||||
|
window.location.host,
|
||||||
|
window.location.pathname
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
var mapPayLink = obj => {
|
||||||
|
obj._data = _.clone(obj)
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||||
|
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||||
|
obj.pay_url = [locationPath, 'link/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currencies: [],
|
||||||
|
fiatRates: {},
|
||||||
|
checker: null,
|
||||||
|
payLinks: [],
|
||||||
|
payLinksTable: {
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nfcTagWriting: false,
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
fixedAmount: true,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
qrCodeDialog: {
|
||||||
|
show: false,
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getPayLinks() {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/lnurlp/api/v1/links?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.payLinks = response.data.map(mapPayLink)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
clearInterval(this.checker)
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeFormDialog() {
|
||||||
|
this.resetFormData()
|
||||||
|
},
|
||||||
|
openQrCodeDialog(linkId) {
|
||||||
|
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||||
|
if (link.currency) this.updateFiatRate(link.currency)
|
||||||
|
|
||||||
|
this.qrCodeDialog.data = {
|
||||||
|
id: link.id,
|
||||||
|
amount:
|
||||||
|
(link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||||
|
' ' +
|
||||||
|
(link.currency || 'sat'),
|
||||||
|
currency: link.currency,
|
||||||
|
comments: link.comment_chars
|
||||||
|
? `${link.comment_chars} characters`
|
||||||
|
: 'no',
|
||||||
|
webhook: link.webhook_url || 'nowhere',
|
||||||
|
success:
|
||||||
|
link.success_text || link.success_url
|
||||||
|
? 'Display message "' +
|
||||||
|
link.success_text +
|
||||||
|
'"' +
|
||||||
|
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||||
|
: 'do nothing',
|
||||||
|
lnurl: link.lnurl,
|
||||||
|
pay_url: link.pay_url,
|
||||||
|
print_url: link.print_url
|
||||||
|
}
|
||||||
|
this.qrCodeDialog.show = true
|
||||||
|
},
|
||||||
|
openUpdateDialog(linkId) {
|
||||||
|
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||||
|
if (link.currency) this.updateFiatRate(link.currency)
|
||||||
|
|
||||||
|
this.formDialog.data = _.clone(link._data)
|
||||||
|
this.formDialog.show = true
|
||||||
|
this.formDialog.fixedAmount =
|
||||||
|
this.formDialog.data.min === this.formDialog.data.max
|
||||||
|
},
|
||||||
|
sendFormData() {
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
|
id: this.formDialog.data.wallet
|
||||||
|
})
|
||||||
|
var data = _.omit(this.formDialog.data, 'wallet')
|
||||||
|
|
||||||
|
if (this.formDialog.fixedAmount) data.max = data.min
|
||||||
|
if (data.currency === 'satoshis') data.currency = null
|
||||||
|
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
this.updatePayLink(wallet, data)
|
||||||
|
} else {
|
||||||
|
this.createPayLink(wallet, data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetFormData() {
|
||||||
|
this.formDialog = {
|
||||||
|
show: false,
|
||||||
|
fixedAmount: true,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatePayLink(wallet, data) {
|
||||||
|
let values = _.omit(
|
||||||
|
_.pick(
|
||||||
|
data,
|
||||||
|
'description',
|
||||||
|
'min',
|
||||||
|
'max',
|
||||||
|
'webhook_url',
|
||||||
|
'success_text',
|
||||||
|
'success_url',
|
||||||
|
'comment_chars',
|
||||||
|
'currency'
|
||||||
|
),
|
||||||
|
(value, key) =>
|
||||||
|
(key === 'webhook_url' ||
|
||||||
|
key === 'success_text' ||
|
||||||
|
key === 'success_url') &&
|
||||||
|
(value === null || value === '')
|
||||||
|
)
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'PUT',
|
||||||
|
'/lnurlp/api/v1/links/' + data.id,
|
||||||
|
wallet.adminkey,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||||
|
this.payLinks.push(mapPayLink(response.data))
|
||||||
|
this.formDialog.show = false
|
||||||
|
this.resetFormData()
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
createPayLink(wallet, data) {
|
||||||
|
LNbits.api
|
||||||
|
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
|
||||||
|
.then(response => {
|
||||||
|
this.getPayLinks()
|
||||||
|
this.formDialog.show = false
|
||||||
|
this.resetFormData()
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deletePayLink(linkId) {
|
||||||
|
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||||
|
.onOk(() => {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/lnurlp/api/v1/links/' + linkId,
|
||||||
|
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||||
|
)
|
||||||
|
.then(response => {
|
||||||
|
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateFiatRate(currency) {
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
|
||||||
|
.then(response => {
|
||||||
|
let rates = _.clone(this.fiatRates)
|
||||||
|
rates[currency] = response.data.rate
|
||||||
|
this.fiatRates = rates
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
writeNfcTag: async function (lnurl) {
|
||||||
|
try {
|
||||||
|
if (typeof NDEFReader == 'undefined') {
|
||||||
|
throw {
|
||||||
|
toString: function () {
|
||||||
|
return 'NFC not supported on this device or browser.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ndef = new NDEFReader()
|
||||||
|
|
||||||
|
this.nfcTagWriting = true
|
||||||
|
this.$q.notify({
|
||||||
|
message: 'Tap your NFC tag to write the LNURL-pay link to it.'
|
||||||
|
})
|
||||||
|
|
||||||
|
await ndef.write({
|
||||||
|
records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.nfcTagWriting = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'NFC tag written successfully.'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.nfcTagWriting = false
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error
|
||||||
|
? error.toString()
|
||||||
|
: 'An unexpected error has occurred.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
var getPayLinks = this.getPayLinks
|
||||||
|
getPayLinks()
|
||||||
|
this.checker = setInterval(() => {
|
||||||
|
getPayLinks()
|
||||||
|
}, 20000)
|
||||||
|
}
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/lnurlp/api/v1/currencies')
|
||||||
|
.then(response => {
|
||||||
|
this.currencies = ['satoshis', ...response.data]
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
79
tasks.py
Normal file
79
tasks.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud import update_payment_extra
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.helpers import get_current_extension_name
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
|
from .crud import get_pay_link
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment):
|
||||||
|
if payment.extra.get("tag") != "lnurlp":
|
||||||
|
return
|
||||||
|
|
||||||
|
if payment.extra.get("wh_status"):
|
||||||
|
# this webhook has already been sent
|
||||||
|
return
|
||||||
|
|
||||||
|
pay_link = await get_pay_link(payment.extra.get("link", -1))
|
||||||
|
if pay_link and pay_link.webhook_url:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
r: httpx.Response = await client.post(
|
||||||
|
pay_link.webhook_url,
|
||||||
|
json={
|
||||||
|
"payment_hash": payment.payment_hash,
|
||||||
|
"payment_request": payment.bolt11,
|
||||||
|
"amount": payment.amount,
|
||||||
|
"comment": payment.extra.get("comment"),
|
||||||
|
"lnurlp": pay_link.id,
|
||||||
|
"body": json.loads(pay_link.webhook_body)
|
||||||
|
if pay_link.webhook_body
|
||||||
|
else "",
|
||||||
|
},
|
||||||
|
headers=json.loads(pay_link.webhook_headers)
|
||||||
|
if pay_link.webhook_headers
|
||||||
|
else None,
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
await mark_webhook_sent(
|
||||||
|
payment.payment_hash,
|
||||||
|
r.status_code,
|
||||||
|
r.is_success,
|
||||||
|
r.reason_phrase,
|
||||||
|
r.text,
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(ex)
|
||||||
|
await mark_webhook_sent(
|
||||||
|
payment.payment_hash, -1, False, "Unexpected Error", str(ex)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_webhook_sent(
|
||||||
|
payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
await update_payment_extra(
|
||||||
|
payment_hash,
|
||||||
|
{
|
||||||
|
"wh_status": status, # keep for backwards compability
|
||||||
|
"wh_success": is_success,
|
||||||
|
"wh_message": reason_phrase,
|
||||||
|
"wh_response": text,
|
||||||
|
},
|
||||||
|
)
|
||||||
138
templates/lnurlp/_api_docs.html
Normal file
138
templates/lnurlp/_api_docs.html
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="List pay links">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">GET</span> /lnurlp/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.base_url }}lnurlp/api/v1/links -H "X-Api-Key:
|
||||||
|
{{ 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>
|
||||||
|
/lnurlp/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.base_url }}lnurlp/api/v1/links/<pay_id>
|
||||||
|
-H "X-Api-Key: {{ 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-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-green">POST</span> /lnurlp/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> "max":
|
||||||
|
<integer> "min": <integer> "comment_chars":
|
||||||
|
<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.base_url }}lnurlp/api/v1/links -d
|
||||||
|
'{"description": <string>, "amount": <integer>, "max":
|
||||||
|
<integer>, "min": <integer>, "comment_chars":
|
||||||
|
<integer>}' -H "Content-type: application/json" -H "X-Api-Key:
|
||||||
|
{{ 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>
|
||||||
|
/lnurlp/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.base_url }}lnurlp/api/v1/links/<pay_id>
|
||||||
|
-d '{"description": <string>, "amount": <integer>}' -H
|
||||||
|
"Content-type: application/json" -H "X-Api-Key: {{
|
||||||
|
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>
|
||||||
|
/lnurlp/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.base_url
|
||||||
|
}}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{
|
||||||
|
user.wallets[0].adminkey }}"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-expansion-item>
|
||||||
31
templates/lnurlp/_lnurl.html
Normal file
31
templates/lnurlp/_lnurl.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<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
|
||||||
|
class="text-secondary"
|
||||||
|
href="https://github.com/fiatjaf/awesome-lnurl"
|
||||||
|
target="_blank"
|
||||||
|
>Awesome LNURL</a
|
||||||
|
>
|
||||||
|
for further information.</small
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
54
templates/lnurlp/display.html
Normal file
54
templates/lnurlp/display.html
Normal file
|
|
@ -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 class="text-secondary" href="lightning:{{ lnurl }}">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-md">
|
||||||
|
<qrcode
|
||||||
|
value="lightning:{{ lnurl }}"
|
||||||
|
:options="{width: 800}"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
|
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
||||||
|
>Copy LNURL</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
icon="nfc"
|
||||||
|
@click="writeNfcTag(' {{ lnurl }} ')"
|
||||||
|
:disable="nfcTagWriting"
|
||||||
|
></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 an LNURL compatible bitcoin wallet to pay.</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>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
345
templates/lnurlp/index.html
Normal file
345
templates/lnurlp/index.html
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
{% 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="primary" @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>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="payLinks"
|
||||||
|
row-key="id"
|
||||||
|
:pagination.sync="payLinksTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width>Description</q-th>
|
||||||
|
<q-th auto-width>Amount</q-th>
|
||||||
|
<q-th auto-width>Currency</q-th>
|
||||||
|
<q-th auto-width></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 auto-width>{{ props.row.description }}</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<span v-if="props.row.min == props.row.max">
|
||||||
|
{{ props.row.min }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
|
||||||
|
</q-td>
|
||||||
|
<q-td>{{ props.row.currency || 'sat' }}</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||||
|
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-if="props.row.success_text || props.row.success_url"
|
||||||
|
size="14px"
|
||||||
|
name="call_to_action"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
On success, show message '{{ props.row.success_text }}'
|
||||||
|
<span v-if="props.row.success_url"
|
||||||
|
>and URL '{{ props.row.success_url }}'</span
|
||||||
|
>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<q-icon
|
||||||
|
v-if="props.row.comment_chars > 0"
|
||||||
|
size="14px"
|
||||||
|
name="insert_comment"
|
||||||
|
>
|
||||||
|
<q-tooltip>
|
||||||
|
{{ props.row.comment_chars }}-char comment allowed
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</q-td>
|
||||||
|
<q-td 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">
|
||||||
|
{{SITE_TITLE}} 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" @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>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.min"
|
||||||
|
type="number"
|
||||||
|
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
||||||
|
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-if="!formDialog.fixedAmount"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.max"
|
||||||
|
type="number"
|
||||||
|
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
||||||
|
label="Max *"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
v-model="formDialog.fixedAmount"
|
||||||
|
label="Fixed amount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
dense
|
||||||
|
:options="currencies"
|
||||||
|
v-model="formDialog.data.currency"
|
||||||
|
:display-value="formDialog.data.currency || 'satoshis'"
|
||||||
|
label="Currency"
|
||||||
|
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
|
||||||
|
@input="updateFiatRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.comment_chars"
|
||||||
|
type="number"
|
||||||
|
label="Comment maximum characters"
|
||||||
|
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.webhook_url"
|
||||||
|
type="text"
|
||||||
|
label="Webhook URL (optional)"
|
||||||
|
hint="A URL to be called whenever this link receives a payment."
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-if="formDialog.data.webhook_url"
|
||||||
|
v-model="formDialog.data.webhook_headers"
|
||||||
|
type="text"
|
||||||
|
label="Webhook headers (optional)"
|
||||||
|
hint="Custom data as JSON string, send headers along with the webhook."
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-if="formDialog.data.webhook_url"
|
||||||
|
v-model="formDialog.data.webhook_body"
|
||||||
|
type="text"
|
||||||
|
label="Webhook custom data (optional)"
|
||||||
|
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.success_text"
|
||||||
|
type="text"
|
||||||
|
label="Success message (optional)"
|
||||||
|
hint="Will be shown to the user in his wallet after a successful payment."
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.success_url"
|
||||||
|
type="text"
|
||||||
|
label="Success URL (optional)"
|
||||||
|
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="formDialog.data.id"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
>Update pay link</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialog.data.wallet == null ||
|
||||||
|
formDialog.data.description == null ||
|
||||||
|
(
|
||||||
|
formDialog.data.min == null ||
|
||||||
|
formDialog.data.min <= 0
|
||||||
|
)
|
||||||
|
"
|
||||||
|
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="'lightning:' + 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 }}<br />
|
||||||
|
<span v-if="qrCodeDialog.data.currency"
|
||||||
|
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
||||||
|
fiatRates[qrCodeDialog.data.currency] ?
|
||||||
|
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
||||||
|
/></span>
|
||||||
|
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
|
||||||
|
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
|
||||||
|
}}<br />
|
||||||
|
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<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"
|
||||||
|
icon="link"
|
||||||
|
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||||
|
><q-tooltip>Copy sharable link</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
icon="nfc"
|
||||||
|
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
||||||
|
:disable="nfcTagWriting"
|
||||||
|
><q-tooltip>Write to NFC</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
icon="print"
|
||||||
|
type="a"
|
||||||
|
:href="qrCodeDialog.data.print_url"
|
||||||
|
target="_blank"
|
||||||
|
><q-tooltip>Print</q-tooltip></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="/lnurlp/static/js/index.js"></script>
|
||||||
|
{% endblock %}
|
||||||
27
templates/lnurlp/print_qr.html
Normal file
27
templates/lnurlp/print_qr.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% extends "print.html" %} {% block page %}
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div class="qr">
|
||||||
|
<qrcode value="lightning:{{ lnurl }}" :options="{width}"></qrcode>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block styles %}
|
||||||
|
<style>
|
||||||
|
.qr {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
created: function () {
|
||||||
|
window.print()
|
||||||
|
},
|
||||||
|
data: function () {
|
||||||
|
return {width: window.innerWidth * 0.5}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
43
views.py
Normal file
43
views.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import lnurlp_ext, lnurlp_renderer
|
||||||
|
from .crud import get_pay_link
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return lnurlp_renderer().TemplateResponse(
|
||||||
|
"lnurlp/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/link/{link_id}", response_class=HTMLResponse)
|
||||||
|
async def display(request: Request, link_id):
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
|
)
|
||||||
|
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
||||||
|
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
||||||
|
async def print_qr(request: Request, link_id):
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
|
)
|
||||||
|
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
||||||
|
return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)
|
||||||
168
views_api.py
Normal file
168
views_api.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Depends, Query, Request
|
||||||
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||||
|
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||||
|
|
||||||
|
from . import lnurlp_ext
|
||||||
|
from .crud import (
|
||||||
|
create_pay_link,
|
||||||
|
delete_pay_link,
|
||||||
|
get_pay_link,
|
||||||
|
get_pay_links,
|
||||||
|
update_pay_link,
|
||||||
|
)
|
||||||
|
from .models import CreatePayLinkData
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/api/v1/currencies")
|
||||||
|
async def api_list_currencies_available():
|
||||||
|
return list(currencies.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||||
|
async def api_links(
|
||||||
|
req: Request,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
all_wallets: bool = Query(False),
|
||||||
|
):
|
||||||
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
|
if all_wallets:
|
||||||
|
user = await get_user(wallet.wallet.user)
|
||||||
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
{**link.dict(), "lnurl": link.lnurl(req)}
|
||||||
|
for link in await get_pay_links(wallet_ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
except LnurlInvalidUrl:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
||||||
|
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_link_retrieve(
|
||||||
|
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||||
|
):
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
return {**link.dict(), **{"lnurl": link.lnurl(r)}}
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||||
|
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_link_create_or_update(
|
||||||
|
data: CreatePayLinkData,
|
||||||
|
request: Request,
|
||||||
|
link_id=None,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
):
|
||||||
|
|
||||||
|
if data.min > data.max:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.currency is None and (
|
||||||
|
round(data.min) != data.min or round(data.max) != data.max or data.min < 1
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.webhook_headers:
|
||||||
|
try:
|
||||||
|
json.loads(data.webhook_headers)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Invalid JSON in webhook_headers.",
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.webhook_body:
|
||||||
|
try:
|
||||||
|
json.loads(data.webhook_body)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Invalid JSON in webhook_body.",
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# database only allows int4 entries for min and max. For fiat currencies,
|
||||||
|
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||||
|
if data.currency and data.fiat_base_multiplier:
|
||||||
|
data.min *= data.fiat_base_multiplier
|
||||||
|
data.max *= data.fiat_base_multiplier
|
||||||
|
|
||||||
|
if data.success_url is not None and not data.success_url.startswith("https://"):
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Success URL must be secure https://...",
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if link_id:
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
link = await update_pay_link(**data.dict(), link_id=link_id)
|
||||||
|
else:
|
||||||
|
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
|
||||||
|
assert link
|
||||||
|
return {**link.dict(), "lnurl": link.lnurl(request)}
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_pay_link(link_id)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_check_fiat_rate(currency):
|
||||||
|
try:
|
||||||
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
|
except AssertionError:
|
||||||
|
rate = None
|
||||||
|
|
||||||
|
return {"rate": rate}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue