Compare commits
9 commits
main
...
v1.0.0-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97be0d792 | ||
|
|
0d2b2cdd79 | ||
|
|
8d2008074e | ||
|
|
60b3c4d46d | ||
|
|
09331f6bbc | ||
|
|
4578a4e746 | ||
|
|
1bae6340fa | ||
|
|
fb5a357eb8 | ||
|
|
fe6cbe4e2d |
33 changed files with 4178 additions and 3798 deletions
24
Makefile
24
Makefile
|
|
@ -5,27 +5,27 @@ format: prettier black ruff
|
|||
check: mypy pyright checkblack checkruff checkprettier
|
||||
|
||||
prettier:
|
||||
uv run ./node_modules/.bin/prettier --write .
|
||||
poetry run ./node_modules/.bin/prettier --write .
|
||||
pyright:
|
||||
uv run ./node_modules/.bin/pyright
|
||||
poetry run ./node_modules/.bin/pyright
|
||||
|
||||
mypy:
|
||||
uv run mypy .
|
||||
poetry run mypy .
|
||||
|
||||
black:
|
||||
uv run black .
|
||||
poetry run black .
|
||||
|
||||
ruff:
|
||||
uv run ruff check . --fix
|
||||
poetry run ruff check . --fix
|
||||
|
||||
checkruff:
|
||||
uv run ruff check .
|
||||
poetry run ruff check .
|
||||
|
||||
checkprettier:
|
||||
uv run ./node_modules/.bin/prettier --check .
|
||||
poetry run ./node_modules/.bin/prettier --check .
|
||||
|
||||
checkblack:
|
||||
uv run black --check .
|
||||
poetry run black --check .
|
||||
|
||||
checkeditorconfig:
|
||||
editorconfig-checker
|
||||
|
|
@ -33,14 +33,14 @@ checkeditorconfig:
|
|||
test:
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
uv run pytest
|
||||
poetry run pytest
|
||||
install-pre-commit-hook:
|
||||
@echo "Installing pre-commit hook to git"
|
||||
@echo "Uninstall the hook with uv run pre-commit uninstall"
|
||||
uv run pre-commit install
|
||||
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||
poetry run pre-commit install
|
||||
|
||||
pre-commit:
|
||||
uv run pre-commit run --all-files
|
||||
poetry run pre-commit run --all-files
|
||||
|
||||
|
||||
checkbundle:
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -1,13 +1,3 @@
|
|||
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
|
||||
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://github.com/lnbits/lnbits)
|
||||
|
||||
# LNURLp - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
|
||||
|
||||
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
|
||||
|
|
@ -22,6 +12,7 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
|
|||
|
||||
1. Create an LNURLp (New Pay link)\
|
||||

|
||||
|
||||
- select your wallets
|
||||
- make a small description
|
||||
- enter amount
|
||||
|
|
@ -34,6 +25,7 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
|
|||
|
||||
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\
|
||||

|
||||
|
||||
|
|
@ -49,7 +41,7 @@ This new version of the extension will give you the option to add a Lightning Ad
|
|||
|
||||
- Open your LNbits instance as super admin (not as a regular user. You will find the SuperUser-ID in your server logs on restart of LNbits. Use that to bookmark and manage LNbits from there in the future.)
|
||||
Now lets install the new version of a given extension like extensively [described in this guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#install-new-extension). In short:
|
||||
- Go to "Manage extensions", click on "ALL", search for e.g. LNURLp, click on "Manage"
|
||||
- Go to "Mange extensions", click on "ALL", search for e.g. LNURLp, click on "Manage"
|
||||
- Open the details of the extension and click on version 0.2.1, click "Install". You´re done!
|
||||
|
||||
[](https://postimg.cc/xqFWtDfq)
|
||||
|
|
@ -65,10 +57,3 @@ Now you can receive sats to your newly created LN address. You will find this in
|
|||
[](https://postimg.cc/3WwsXJHP)
|
||||
|
||||
</details>
|
||||
|
||||
## Powered by LNbits
|
||||
|
||||
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
|
||||
|
||||
[](https://shop.lnbits.com/)
|
||||
[](https://my.lnbits.com/login)
|
||||
|
|
|
|||
45
__init__.py
45
__init__.py
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ lnurlp_ext.include_router(lnurlp_generic_router)
|
|||
lnurlp_ext.include_router(lnurlp_api_router)
|
||||
lnurlp_ext.include_router(lnurlp_lnurl_router)
|
||||
|
||||
scheduled_tasks: list[asyncio.Task] = []
|
||||
scheduled_tasks: List[asyncio.Task] = []
|
||||
|
||||
|
||||
def lnurlp_stop():
|
||||
|
|
@ -42,48 +43,12 @@ def lnurlp_start():
|
|||
task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices)
|
||||
scheduled_tasks.append(task)
|
||||
|
||||
# Expose lnurlp's CRUD over the LNbits nostr transport so an HTTP-
|
||||
# allergic client (e.g. lamassu-next ATM) can manage PayLinks over
|
||||
# kind-21000 encrypted events. Also wires the link-owner resolver so
|
||||
# `subscribe_payments({tag:"lnurlp", link_id:...})` can verify
|
||||
# ownership of the underlying wallet. No-op if the core transport
|
||||
# module isn't present in the LNbits build.
|
||||
try:
|
||||
from lnbits.core.services.nostr_transport.dispatcher import (
|
||||
AUTH_ACCOUNT,
|
||||
AUTH_WALLET,
|
||||
register_rpc,
|
||||
)
|
||||
from lnbits.core.services.nostr_transport.subscriptions import (
|
||||
register_link_owner_resolver,
|
||||
)
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
from .transport_rpcs import (
|
||||
handle_lnurlp_create,
|
||||
handle_lnurlp_delete,
|
||||
handle_lnurlp_get,
|
||||
handle_lnurlp_list,
|
||||
handle_lnurlp_update,
|
||||
resolve_lnurlp_owner,
|
||||
)
|
||||
|
||||
register_rpc("lnurlp_create", handle_lnurlp_create, AUTH_WALLET)
|
||||
register_rpc("lnurlp_get", handle_lnurlp_get, AUTH_WALLET)
|
||||
register_rpc("lnurlp_list", handle_lnurlp_list, AUTH_ACCOUNT)
|
||||
register_rpc("lnurlp_update", handle_lnurlp_update, AUTH_WALLET)
|
||||
register_rpc("lnurlp_delete", handle_lnurlp_delete, AUTH_WALLET)
|
||||
# lnurlp stamps `extra["link"] = link.id` on settlement
|
||||
# (views_lnurl.py:86), which is the default extras-key, so no override.
|
||||
register_link_owner_resolver("lnurlp", resolve_lnurlp_owner)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"db",
|
||||
"lnurlp_ext",
|
||||
"lnurlp_redirect_paths",
|
||||
"lnurlp_start",
|
||||
"lnurlp_static_files",
|
||||
"lnurlp_redirect_paths",
|
||||
"lnurlp_stop",
|
||||
"lnurlp_start",
|
||||
"db",
|
||||
]
|
||||
|
|
|
|||
19
config.json
19
config.json
|
|
@ -1,12 +1,8 @@
|
|||
{
|
||||
"id": "paylink",
|
||||
"version": "1.3.0",
|
||||
"name": "Pay Links",
|
||||
"repo": "https://github.com/lnbits/lnurlp",
|
||||
"short_description": "Make static reusable LNURL pay links or lightning addresses",
|
||||
"description": "",
|
||||
"short_description": "Make reusable LNURL pay links",
|
||||
"tile": "/lnurlp/static/image/lnurl-pay.png",
|
||||
"min_lnbits_version": "1.4.0",
|
||||
"min_lnbits_version": "1.0.0",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "arcbtc",
|
||||
|
|
@ -27,11 +23,6 @@
|
|||
"name": "callebtc",
|
||||
"uri": "https://github.com/callebtc",
|
||||
"role": "Contributor"
|
||||
},
|
||||
{
|
||||
"name": "dni",
|
||||
"uri": "https://github.com/dni",
|
||||
"role": "Contributor"
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
|
|
@ -54,9 +45,5 @@
|
|||
],
|
||||
"description_md": "https://raw.githubusercontent.com/lnbits/lnurlp/main/description.md",
|
||||
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/lnurlp/main/toc.md",
|
||||
"license": "MIT",
|
||||
"paid_features": "",
|
||||
"tags": ["Merchant", "Payments"],
|
||||
"donate": "",
|
||||
"hidden": false
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
|
|||
52
crud.py
52
crud.py
|
|
@ -1,20 +1,18 @@
|
|||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from pynostr.key import PrivateKey
|
||||
|
||||
from .models import CreatePayLinkData, LnurlpSettings, PayLink
|
||||
from .nostr.key import PrivateKey
|
||||
|
||||
db = Database("ext_lnurlp")
|
||||
|
||||
|
||||
async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
||||
settings = await db.fetchone(
|
||||
"SELECT * FROM lnurlp.settings LIMIT 1", model=LnurlpSettings
|
||||
)
|
||||
if settings:
|
||||
return settings
|
||||
row = await db.fetchone("SELECT * FROM lnurlp.settings LIMIT 1")
|
||||
if row:
|
||||
return LnurlpSettings(**row)
|
||||
else:
|
||||
settings = LnurlpSettings(nostr_private_key=PrivateKey().hex())
|
||||
await db.insert("lnurlp.settings", settings)
|
||||
|
|
@ -22,7 +20,7 @@ async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
|||
|
||||
|
||||
async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings:
|
||||
await db.update("lnurlp.settings", settings, "")
|
||||
await db.update("lnurlp.settings", settings)
|
||||
return settings
|
||||
|
||||
|
||||
|
|
@ -30,19 +28,19 @@ async def delete_lnurlp_settings() -> None:
|
|||
await db.execute("DELETE FROM lnurlp.settings")
|
||||
|
||||
|
||||
async def get_pay_link_by_username(username: str) -> PayLink | None:
|
||||
return await db.fetchone(
|
||||
async def get_pay_link_by_username(username: str) -> Optional[PayLink]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlp.pay_links WHERE username = :username",
|
||||
{"username": username},
|
||||
PayLink,
|
||||
)
|
||||
return PayLink(**row) if row else None
|
||||
|
||||
|
||||
async def create_pay_link(data: CreatePayLinkData) -> PayLink:
|
||||
|
||||
link_id = urlsafe_short_hash()[:6]
|
||||
|
||||
assert data.wallet, "Wallet is required"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
link = PayLink(
|
||||
id=link_id,
|
||||
|
|
@ -54,6 +52,7 @@ async def create_pay_link(data: CreatePayLinkData) -> PayLink:
|
|||
served_pr=0,
|
||||
username=data.username,
|
||||
zaps=data.zaps,
|
||||
domain=None,
|
||||
webhook_url=data.webhook_url,
|
||||
webhook_headers=data.webhook_headers,
|
||||
webhook_body=data.webhook_body,
|
||||
|
|
@ -62,44 +61,39 @@ async def create_pay_link(data: CreatePayLinkData) -> PayLink:
|
|||
currency=data.currency,
|
||||
comment_chars=data.comment_chars,
|
||||
fiat_base_multiplier=data.fiat_base_multiplier,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
disposable=data.disposable if data.disposable is not None else True,
|
||||
domain=data.domain,
|
||||
)
|
||||
|
||||
await db.insert("lnurlp.pay_links", link)
|
||||
return link
|
||||
|
||||
|
||||
async def get_address_data(username: str) -> PayLink | None:
|
||||
return await db.fetchone(
|
||||
async def get_address_data(username: str) -> Optional[PayLink]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlp.pay_links WHERE username = :username",
|
||||
{"username": username},
|
||||
PayLink,
|
||||
)
|
||||
return PayLink(**row) if row else None
|
||||
|
||||
|
||||
async def get_pay_link(link_id: str) -> PayLink | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM lnurlp.pay_links WHERE id = :id",
|
||||
{"id": link_id},
|
||||
PayLink,
|
||||
async def get_pay_link(link_id: str) -> Optional[PayLink]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM lnurlp.pay_links WHERE id = :id", {"id": link_id}
|
||||
)
|
||||
return PayLink(**row) if row else None
|
||||
|
||||
|
||||
async def get_pay_links(wallet_ids: str | list[str]) -> list[PayLink]:
|
||||
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) ORDER BY Id",
|
||||
model=PayLink,
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) ORDER BY Id"
|
||||
)
|
||||
return [PayLink(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_pay_link(link: PayLink) -> PayLink:
|
||||
link.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("lnurlp.pay_links", link)
|
||||
return link
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1 @@
|
|||
Create static LNURL-pay links and Lightning addresses for receiving payments.
|
||||
|
||||
Its functions include:
|
||||
|
||||
- Generating reusable LNURL-pay QR codes
|
||||
- Creating custom Lightning addresses
|
||||
- Setting minimum and maximum payment amounts
|
||||
- Adding payment descriptions and metadata
|
||||
|
||||
A foundational tool for anyone who wants a simple, reusable way to receive Lightning payments with a static QR code or Lightning address.
|
||||
Create a static LNURLp or LNaddress people can use to pay.
|
||||
|
|
|
|||
19
helpers.py
19
helpers.py
|
|
@ -1,6 +1,4 @@
|
|||
from fastapi import Request
|
||||
from lnurl import encode as lnurl_encode
|
||||
from pynostr.key import PrivateKey
|
||||
from .nostr.key import PrivateKey
|
||||
|
||||
|
||||
def parse_nostr_private_key(key: str) -> PrivateKey:
|
||||
|
|
@ -8,18 +6,3 @@ def parse_nostr_private_key(key: str) -> PrivateKey:
|
|||
return PrivateKey.from_nsec(key)
|
||||
else:
|
||||
return PrivateKey(bytes.fromhex(key))
|
||||
|
||||
|
||||
def lnurl_encode_link(req: Request, link_id: str, domain: str | None = None) -> str:
|
||||
if domain:
|
||||
url_str = f"https://{domain}/lnurlp/{link_id}"
|
||||
return str(lnurl_encode(url_str).bech32)
|
||||
|
||||
url = req.url_for("lnurlp.api_lnurl_response", link_id=link_id)
|
||||
url = url.replace(path=url.path)
|
||||
url_str = str(url)
|
||||
if url.netloc.endswith(".onion"):
|
||||
# change url string scheme to http
|
||||
url_str = url_str.replace("https://", "http://")
|
||||
|
||||
return str(lnurl_encode(url_str).bech32)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
from time import time
|
||||
|
||||
from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial pay table.
|
||||
"""
|
||||
await db.execute(f"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlp.pay_links (
|
||||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
|
|
@ -16,7 +12,8 @@ async def m001_initial(db):
|
|||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_webhooks_and_success_actions(db):
|
||||
|
|
@ -26,14 +23,16 @@ async def m002_webhooks_and_success_actions(db):
|
|||
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"""
|
||||
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):
|
||||
|
|
@ -82,7 +81,8 @@ async def m006_redux(db):
|
|||
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"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE lnurlp.pay_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
|
|
@ -100,7 +100,8 @@ async def m006_redux(db):
|
|||
webhook_headers TEXT,
|
||||
webhook_body TEXT
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
for row in [
|
||||
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
|
||||
|
|
@ -166,11 +167,13 @@ async def m009_add_settings(db):
|
|||
"""
|
||||
Add extension settings table
|
||||
"""
|
||||
await db.execute("""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE lnurlp.settings (
|
||||
nostr_private_key TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m010_add_pay_link_domain(db):
|
||||
|
|
@ -178,31 +181,3 @@ async def m010_add_pay_link_domain(db):
|
|||
Add domain to pay links
|
||||
"""
|
||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN domain TEXT;")
|
||||
|
||||
|
||||
async def m011_add_created_at(db: Connection):
|
||||
"""
|
||||
Add created_at to pay links
|
||||
"""
|
||||
|
||||
await db.execute(f"""ALTER TABLE lnurlp.pay_links ADD COLUMN
|
||||
created_at TIMESTAMP DEFAULT {db.timestamp_column_default}""")
|
||||
await db.execute(f"""ALTER TABLE lnurlp.pay_links ADD COLUMN
|
||||
updated_at TIMESTAMP DEFAULT {db.timestamp_column_default}""")
|
||||
|
||||
now = int(time())
|
||||
await db.execute(
|
||||
f"""
|
||||
UPDATE lnurlp.pay_links
|
||||
SET created_at = {db.timestamp_placeholder('now')},
|
||||
updated_at = {db.timestamp_placeholder('now')}
|
||||
WHERE created_at IS NULL AND updated_at IS NULL
|
||||
""",
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_disposable(db: Connection):
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurlp.pay_links ADD COLUMN disposable BOOLEAN DEFAULT TRUE"
|
||||
)
|
||||
|
|
|
|||
106
models.py
106
models.py
|
|
@ -1,10 +1,13 @@
|
|||
from datetime import datetime, timezone
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, Field
|
||||
from pynostr.key import PrivateKey
|
||||
from fastapi import Query, Request
|
||||
from lnurl import encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .helpers import parse_nostr_private_key
|
||||
from .nostr.key import PrivateKey
|
||||
|
||||
|
||||
class LnurlpSettings(BaseModel):
|
||||
|
|
@ -21,21 +24,19 @@ class LnurlpSettings(BaseModel):
|
|||
|
||||
class CreatePayLinkData(BaseModel):
|
||||
description: str
|
||||
wallet: str | None = None
|
||||
wallet: Optional[str] = None
|
||||
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, le=799)
|
||||
currency: str | None = Query(None)
|
||||
webhook_url: str | None = Query(None)
|
||||
webhook_headers: str | None = Query(None)
|
||||
webhook_body: str | None = Query(None)
|
||||
success_text: str | None = Query(None)
|
||||
success_url: str | None = Query(None)
|
||||
fiat_base_multiplier: int | None = Query(100, ge=1)
|
||||
username: str | None = Query(None)
|
||||
zaps: bool | None = Query(False)
|
||||
disposable: bool | None = Query(True)
|
||||
domain: str | None = Query(None)
|
||||
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)
|
||||
username: str = Query(None)
|
||||
zaps: Optional[bool] = Query(False)
|
||||
|
||||
|
||||
class PayLink(BaseModel):
|
||||
|
|
@ -43,50 +44,37 @@ class PayLink(BaseModel):
|
|||
wallet: str
|
||||
description: str
|
||||
min: float
|
||||
max: float
|
||||
served_meta: int
|
||||
served_pr: int
|
||||
username: Optional[str]
|
||||
zaps: Optional[bool]
|
||||
domain: Optional[str]
|
||||
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
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
lnurl: str | None = Field(
|
||||
default=None,
|
||||
no_database=True,
|
||||
deprecated=True,
|
||||
description=(
|
||||
"Deprecated: Instead of using this bech32 encoded string, dynamically "
|
||||
"generate your own static link (lud17/bech32) on the client side. "
|
||||
"Example: lnurlp://${window.location.hostname}/lnurlp/${paylink_id}"
|
||||
),
|
||||
)
|
||||
username: str | None = None
|
||||
zaps: bool | None = None
|
||||
webhook_url: str | None = None
|
||||
webhook_headers: str | None = None
|
||||
webhook_body: str | None = None
|
||||
success_text: str | None = None
|
||||
success_url: str | None = None
|
||||
currency: str | None = None
|
||||
fiat_base_multiplier: int | None = None
|
||||
disposable: bool
|
||||
domain: str | None = None
|
||||
|
||||
|
||||
class PublicPayLink(BaseModel):
|
||||
id: str
|
||||
username: str | None = None
|
||||
description: str
|
||||
min: float
|
||||
max: float
|
||||
domain: str | None = None
|
||||
currency: str | None = None
|
||||
lnurl: str | None = Field(
|
||||
default=None,
|
||||
no_database=True,
|
||||
deprecated=True,
|
||||
description=(
|
||||
"Deprecated: Instead of using this bech32 encoded string, dynamically "
|
||||
"generate your own static link (lud17/bech32) on the client side. "
|
||||
"Example: lnurlp://${window.location.hostname}/lnurlp/${paylink_id}"
|
||||
),
|
||||
)
|
||||
fiat_base_multiplier: int
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
|
||||
url_str = str(url)
|
||||
if url.netloc.endswith(".onion"):
|
||||
# change url string scheme to http
|
||||
url_str = url_str.replace("https://", "http://")
|
||||
|
||||
return lnurl_encode(url_str)
|
||||
|
||||
@property
|
||||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
if self.domain and self.username:
|
||||
text = f"Payment to {self.username}"
|
||||
identifier = f"{self.username}@{self.domain}"
|
||||
metadata = [["text/plain", text], ["text/identifier", identifier]]
|
||||
else:
|
||||
metadata = [["text/plain", self.description]]
|
||||
|
||||
return LnurlPayMetadata(json.dumps(metadata))
|
||||
|
|
|
|||
153
nostr/bech32.py
Normal file
153
nostr/bech32.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Copyright (c) 2017, 2020 Pieter Wuille
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
"""Reference implementation for Bech32/Bech32m and segwit addresses."""
|
||||
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Encoding(Enum):
|
||||
"""Enumeration type to list the various supported encodings."""
|
||||
|
||||
BECH32 = 1
|
||||
BECH32M = 2
|
||||
|
||||
|
||||
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
BECH32M_CONST = 0x2BC830A3
|
||||
|
||||
|
||||
def bech32_polymod(values):
|
||||
"""Internal function that computes the Bech32 checksum."""
|
||||
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
chk ^= generator[i] if ((top >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def bech32_hrp_expand(hrp):
|
||||
"""Expand the HRP into values for checksum computation."""
|
||||
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
||||
|
||||
|
||||
def bech32_verify_checksum(hrp, data):
|
||||
"""Verify a checksum given HRP and converted data characters."""
|
||||
const = bech32_polymod(bech32_hrp_expand(hrp) + data)
|
||||
if const == 1:
|
||||
return Encoding.BECH32
|
||||
if const == BECH32M_CONST:
|
||||
return Encoding.BECH32M
|
||||
return None
|
||||
|
||||
|
||||
def bech32_create_checksum(hrp, data, spec):
|
||||
"""Compute the checksum values given HRP and data."""
|
||||
values = bech32_hrp_expand(hrp) + data
|
||||
const = BECH32M_CONST if spec == Encoding.BECH32M else 1
|
||||
polymod = bech32_polymod([*values, 0, 0, 0, 0, 0, 0]) ^ const
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def bech32_encode(hrp, data, spec):
|
||||
"""Compute a Bech32 string given HRP and data values."""
|
||||
combined = data + bech32_create_checksum(hrp, data, spec)
|
||||
return hrp + "1" + "".join([CHARSET[d] for d in combined])
|
||||
|
||||
|
||||
def bech32_decode(bech):
|
||||
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
|
||||
if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (
|
||||
bech.lower() != bech and bech.upper() != bech
|
||||
):
|
||||
return (None, None, None)
|
||||
bech = bech.lower()
|
||||
pos = bech.rfind("1")
|
||||
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
|
||||
return (None, None, None)
|
||||
if not all(x in CHARSET for x in bech[pos + 1 :]):
|
||||
return (None, None, None)
|
||||
hrp = bech[:pos]
|
||||
data = [CHARSET.find(x) for x in bech[pos + 1 :]]
|
||||
spec = bech32_verify_checksum(hrp, data)
|
||||
if spec is None:
|
||||
return (None, None, None)
|
||||
return (hrp, data[:-6], spec)
|
||||
|
||||
|
||||
def convertbits(data, frombits, tobits, pad=True):
|
||||
"""General power-of-2 base conversion."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret = []
|
||||
maxv = (1 << tobits) - 1
|
||||
max_acc = (1 << (frombits + tobits - 1)) - 1
|
||||
for value in data:
|
||||
if value < 0 or (value >> frombits):
|
||||
return None
|
||||
acc = ((acc << frombits) | value) & max_acc
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad:
|
||||
if bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
||||
return None
|
||||
return ret
|
||||
|
||||
|
||||
def decode(hrp, addr):
|
||||
"""Decode a segwit address."""
|
||||
hrpgot, data, spec = bech32_decode(addr)
|
||||
assert data, "Invalid bech32 string"
|
||||
if hrpgot != hrp:
|
||||
return (None, None)
|
||||
decoded = convertbits(data[1:], 5, 8, False)
|
||||
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
|
||||
return (None, None)
|
||||
if data[0] > 16:
|
||||
return (None, None)
|
||||
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
|
||||
return (None, None)
|
||||
if (
|
||||
data[0] == 0
|
||||
and spec != Encoding.BECH32
|
||||
or data[0] != 0
|
||||
and spec != Encoding.BECH32M
|
||||
):
|
||||
return (None, None)
|
||||
return (data[0], decoded)
|
||||
|
||||
|
||||
def encode(hrp, witver, witprog):
|
||||
"""Encode a segwit address."""
|
||||
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
|
||||
bits = convertbits(witprog, 8, 5)
|
||||
assert bits, "Invalid witness program"
|
||||
ret = bech32_encode(hrp, [witver, *bits], spec)
|
||||
if decode(hrp, ret) == (None, None):
|
||||
return None
|
||||
return ret
|
||||
133
nostr/event.py
Normal file
133
nostr/event.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from hashlib import sha256
|
||||
from typing import List, Optional
|
||||
|
||||
from secp256k1 import PublicKey
|
||||
|
||||
from .message_type import ClientMessageType
|
||||
|
||||
|
||||
class EventKind(IntEnum):
|
||||
SET_METADATA = 0
|
||||
TEXT_NOTE = 1
|
||||
RECOMMEND_RELAY = 2
|
||||
CONTACTS = 3
|
||||
ENCRYPTED_DIRECT_MESSAGE = 4
|
||||
DELETE = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
content: Optional[str] = None
|
||||
public_key: Optional[str] = None
|
||||
created_at: Optional[int] = None
|
||||
kind: int = EventKind.TEXT_NOTE
|
||||
tags: List[List[str]] = field(
|
||||
default_factory=list
|
||||
) # Dataclasses require special handling when the default value is a mutable type
|
||||
signature: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.content is not None and not isinstance(self.content, str):
|
||||
# DMs initialize content to None but all other kinds should pass in a str
|
||||
raise TypeError("Argument 'content' must be of type str")
|
||||
|
||||
if self.created_at is None:
|
||||
self.created_at = int(time.time())
|
||||
|
||||
@staticmethod
|
||||
def serialize(
|
||||
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str
|
||||
) -> bytes:
|
||||
data = [0, public_key, created_at, kind, tags, content]
|
||||
data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||
return data_str.encode()
|
||||
|
||||
@staticmethod
|
||||
def compute_id(
|
||||
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str
|
||||
):
|
||||
return sha256(
|
||||
Event.serialize(public_key, created_at, kind, tags, content)
|
||||
).hexdigest()
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
# Always recompute the id to reflect the up-to-date state of the Event
|
||||
assert self.public_key, "Event public key is missing"
|
||||
assert self.created_at, "Event created_at is missing"
|
||||
assert self.content, "Event content is missing"
|
||||
return Event.compute_id(
|
||||
self.public_key, self.created_at, self.kind, self.tags, self.content
|
||||
)
|
||||
|
||||
def add_pubkey_ref(self, pubkey: str):
|
||||
"""Adds a reference to a pubkey as a 'p' tag"""
|
||||
self.tags.append(["p", pubkey])
|
||||
|
||||
def add_event_ref(self, event_id: str):
|
||||
"""Adds a reference to an event_id as an 'e' tag"""
|
||||
self.tags.append(["e", event_id])
|
||||
|
||||
def verify(self) -> bool:
|
||||
assert self.public_key, "Event public key is missing"
|
||||
pub_key = PublicKey(
|
||||
bytes.fromhex("02" + self.public_key), True
|
||||
) # add 02 for schnorr (bip340)
|
||||
assert self.signature, "Event signature is missing"
|
||||
return pub_key.schnorr_verify(
|
||||
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
|
||||
)
|
||||
|
||||
def to_message(self) -> str:
|
||||
return json.dumps(
|
||||
[
|
||||
ClientMessageType.EVENT,
|
||||
{
|
||||
"id": self.id,
|
||||
"pubkey": self.public_key,
|
||||
"created_at": self.created_at,
|
||||
"kind": self.kind,
|
||||
"tags": self.tags,
|
||||
"content": self.content,
|
||||
"sig": self.signature,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptedDirectMessage(Event):
|
||||
recipient_pubkey: Optional[str] = None
|
||||
cleartext_content: Optional[str] = None
|
||||
reference_event_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.content is not None:
|
||||
self.cleartext_content = self.content
|
||||
self.content = None
|
||||
|
||||
if self.recipient_pubkey is None:
|
||||
raise Exception("Must specify a recipient_pubkey.")
|
||||
|
||||
self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE
|
||||
super().__post_init__()
|
||||
|
||||
# Must specify the DM recipient's pubkey in a 'p' tag
|
||||
self.add_pubkey_ref(self.recipient_pubkey)
|
||||
|
||||
# Optionally specify a reference event (DM) this is a reply to
|
||||
if self.reference_event_id is not None:
|
||||
self.add_event_ref(self.reference_event_id)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
if self.content is None:
|
||||
raise Exception(
|
||||
"EncryptedDirectMessage `id` is undefined until its "
|
||||
"message is encrypted and stored in the `content` field"
|
||||
)
|
||||
return super().id
|
||||
160
nostr/key.py
Normal file
160
nostr/key.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import base64
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
import secp256k1
|
||||
from cffi import FFI
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from . import bech32
|
||||
from .event import EncryptedDirectMessage, EventKind
|
||||
|
||||
|
||||
class PublicKey:
|
||||
def __init__(self, raw_bytes: bytes) -> None:
|
||||
self.raw_bytes = raw_bytes
|
||||
|
||||
def bech32(self) -> str:
|
||||
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5)
|
||||
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32)
|
||||
|
||||
def hex(self) -> str:
|
||||
return self.raw_bytes.hex()
|
||||
|
||||
def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
|
||||
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
|
||||
return pk.schnorr_verify(
|
||||
bytes.fromhex(message_hash), bytes.fromhex(sig), None, True
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_npub(cls, npub: str):
|
||||
"""Load a PublicKey from its bech32/npub form"""
|
||||
hrp, data, spec = bech32.bech32_decode(npub)
|
||||
assert data, "Invalid npub"
|
||||
bits = bech32.convertbits(data, 5, 8)
|
||||
assert bits, "Invalid npub"
|
||||
raw_public_key = bits[:-1]
|
||||
return cls(bytes(raw_public_key))
|
||||
|
||||
|
||||
class PrivateKey:
|
||||
def __init__(self, raw_secret: Optional[bytes] = None) -> None:
|
||||
if raw_secret is not None:
|
||||
self.raw_secret = raw_secret
|
||||
else:
|
||||
self.raw_secret = secrets.token_bytes(32)
|
||||
|
||||
sk = secp256k1.PrivateKey(self.raw_secret)
|
||||
assert sk.pubkey, "Invalid public"
|
||||
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
|
||||
|
||||
@classmethod
|
||||
def from_nsec(cls, nsec: str):
|
||||
"""Load a PrivateKey from its bech32/nsec form"""
|
||||
hrp, data, spec = bech32.bech32_decode(nsec)
|
||||
bits = bech32.convertbits(data, 5, 8)
|
||||
assert bits, "Invalid nsec"
|
||||
raw_secret = bits[:-1]
|
||||
return cls(bytes(raw_secret))
|
||||
|
||||
def bech32(self) -> str:
|
||||
converted_bits = bech32.convertbits(self.raw_secret, 8, 5)
|
||||
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32)
|
||||
|
||||
def hex(self) -> str:
|
||||
return self.raw_secret.hex()
|
||||
|
||||
def tweak_add(self, scalar: bytes) -> bytes:
|
||||
sk = secp256k1.PrivateKey(self.raw_secret)
|
||||
return sk.tweak_add(scalar)
|
||||
|
||||
def compute_shared_secret(self, public_key_hex: str) -> bytes:
|
||||
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
|
||||
return pk.ecdh(self.raw_secret, hashfn=copy_x)
|
||||
|
||||
def encrypt_message(self, message: str, public_key_hex: str) -> str:
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded_data = padder.update(message.encode()) + padder.finalize()
|
||||
|
||||
iv = secrets.token_bytes(16)
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)
|
||||
)
|
||||
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
|
||||
|
||||
msg = base64.b64encode(encrypted_message).decode()
|
||||
return f"{msg}?iv={base64.b64encode(iv).decode()}"
|
||||
|
||||
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
|
||||
assert dm.recipient_pubkey, "Recipient public key must be set"
|
||||
dm.content = self.encrypt_message(
|
||||
message=dm.cleartext_content or "", public_key_hex=dm.recipient_pubkey
|
||||
)
|
||||
|
||||
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
|
||||
encoded_data = encoded_message.split("?iv=")
|
||||
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
|
||||
|
||||
iv = base64.b64decode(encoded_iv)
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)
|
||||
)
|
||||
encrypted_content = base64.b64decode(encoded_content)
|
||||
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
|
||||
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
|
||||
|
||||
return unpadded_data.decode()
|
||||
|
||||
def sign_message_hash(self, message_hash: bytes) -> str:
|
||||
sk = secp256k1.PrivateKey(self.raw_secret)
|
||||
sig = sk.schnorr_sign(message_hash, None, raw=True)
|
||||
return sig.hex()
|
||||
|
||||
def sign_event(self, event: EncryptedDirectMessage) -> None:
|
||||
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
|
||||
self.encrypt_dm(event)
|
||||
if event.public_key is None:
|
||||
event.public_key = self.public_key.hex()
|
||||
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.raw_secret == other.raw_secret
|
||||
|
||||
|
||||
def mine_vanity_key(
|
||||
prefix: Optional[str] = None, suffix: Optional[str] = None
|
||||
) -> PrivateKey:
|
||||
if prefix is None and suffix is None:
|
||||
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
|
||||
|
||||
while True:
|
||||
sk = PrivateKey()
|
||||
if (
|
||||
prefix is not None
|
||||
and not sk.public_key.bech32()[5 : 5 + len(prefix)] == prefix
|
||||
):
|
||||
continue
|
||||
if suffix is not None and not sk.public_key.bech32()[-len(suffix) :] == suffix:
|
||||
continue
|
||||
break
|
||||
|
||||
return sk
|
||||
|
||||
|
||||
ffi = FFI()
|
||||
|
||||
|
||||
@ffi.callback(
|
||||
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
|
||||
)
|
||||
def copy_x(output, x32, y32, data):
|
||||
ffi.memmove(output, x32, 32)
|
||||
return 1
|
||||
20
nostr/message_type.py
Normal file
20
nostr/message_type.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
class ClientMessageType:
|
||||
EVENT = "EVENT"
|
||||
REQUEST = "REQ"
|
||||
CLOSE = "CLOSE"
|
||||
|
||||
|
||||
class RelayMessageType:
|
||||
EVENT = "EVENT"
|
||||
NOTICE = "NOTICE"
|
||||
END_OF_STORED_EVENTS = "EOSE"
|
||||
|
||||
@staticmethod
|
||||
def is_valid(relay_type: str) -> bool:
|
||||
if (
|
||||
relay_type == RelayMessageType.EVENT
|
||||
or relay_type == RelayMessageType.NOTICE
|
||||
or relay_type == RelayMessageType.END_OF_STORED_EVENTS
|
||||
):
|
||||
return True
|
||||
return False
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -9,8 +9,8 @@
|
|||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"prettier": "^3.7.4",
|
||||
"pyright": "^1.1.407"
|
||||
"prettier": "^3.2.5",
|
||||
"pyright": "^1.1.358"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
|
|
@ -18,7 +18,6 @@
|
|||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
|
|
@ -28,10 +27,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"license": "MIT",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -43,10 +41,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/pyright": {
|
||||
"version": "1.1.407",
|
||||
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.407.tgz",
|
||||
"integrity": "sha512-zU+peTFEVUdokNQyUBhGQYt+NWI/3aiNlvBbDBSsn5Ti334XElFUs+GDjQzCbchYfkT+DvMAT3OkMcV4CuEfDg==",
|
||||
"license": "MIT",
|
||||
"version": "1.1.372",
|
||||
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.372.tgz",
|
||||
"integrity": "sha512-S0XYmTQWK+ha9FTIWviNk91UnbD569wPUCNEltSqtHeTJhbHj5z3LkOKiqXAOvn72BBfylcgpQqyQHsocmQtiQ==",
|
||||
"bin": {
|
||||
"pyright": "index.js",
|
||||
"pyright-langserver": "langserver.index.js"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"prettier": "^3.7.4",
|
||||
"pyright": "^1.1.407"
|
||||
"prettier": "^3.2.5",
|
||||
"pyright": "^1.1.358"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2586
poetry.lock
generated
Normal file
2586
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,40 +1,40 @@
|
|||
[project]
|
||||
[tool.poetry]
|
||||
name = "lnbits-lnurlp"
|
||||
version = "0.0.0"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/lnbits" }
|
||||
dependencies = ["lnbits>1"]
|
||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||
|
||||
[tool.poetry]
|
||||
package-mode = false
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10 | ^3.9"
|
||||
lnbits = {version = "*", allow-prereleases = true}
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=24.3.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest>=7.3.2",
|
||||
"mypy==1.17.1",
|
||||
"pre-commit>=3.2.2",
|
||||
"ruff>=0.3.2",
|
||||
"types-cffi>=1.16.0.20240331",
|
||||
]
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^24.3.0"
|
||||
pytest-asyncio = "^0.21.0"
|
||||
pytest = "^7.3.2"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.2.2"
|
||||
ruff = "^0.3.2"
|
||||
types-cffi = "^1.16.0.20240331"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.mypy]
|
||||
# exclude = "(nostr/*)"
|
||||
plugins = "pydantic.mypy"
|
||||
|
||||
[tool.pydantic-mypy]
|
||||
init_forbid_extra = true
|
||||
init_typed = true
|
||||
warn_required_dynamic_aliases = true
|
||||
warn_untyped_fields = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"lnbits.*",
|
||||
"pynostr.*",
|
||||
"lnurl.*",
|
||||
"loguru.*",
|
||||
"fastapi.*",
|
||||
"pydantic.*",
|
||||
"pyqrcode.*",
|
||||
"shortuuid.*",
|
||||
"httpx.*",
|
||||
"websocket.*",
|
||||
"secp256k1.*",
|
||||
]
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
window.PageLnurlpPublic = {
|
||||
template: '#page-lnurlp-public',
|
||||
data() {
|
||||
return {
|
||||
url: '',
|
||||
payLink: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUrl(link_id, domain) {
|
||||
this.url = `https://${domain || window.location.host}/lnurlp/${link_id}`
|
||||
},
|
||||
getPayLink() {
|
||||
this.api
|
||||
.request('GET', `/lnurlp/api/v1/links/public/${this.$route.params.id}`)
|
||||
.then(res => {
|
||||
this.payLink = res.data
|
||||
this.setUrl(this.payLink.id, this.payLink.domain)
|
||||
})
|
||||
.catch(this.utils.notifyApiError)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.setUrl(this.$route.params.id)
|
||||
this.getPayLink()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<template id="page-lnurlp-public">
|
||||
<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">
|
||||
<lnbits-qrcode-lnurl :url="url" :nfc="true"></lnbits-qrcode-lnurl>
|
||||
</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>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="info"
|
||||
label="Powered by LNURL"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
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>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
686
static/index.vue
686
static/index.vue
|
|
@ -1,686 +0,0 @@
|
|||
<template id="page-lnurlp">
|
||||
<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
|
||||
>
|
||||
<lnbits-extension-settings-btn-dialog
|
||||
v-if="g.user.admin"
|
||||
:endpoint="endpoint"
|
||||
:options="settings"
|
||||
/>
|
||||
</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
|
||||
:rows="payLinks"
|
||||
:columns="payLinksTable.columns"
|
||||
row-key="id"
|
||||
v-model:pagination="payLinksTable.pagination"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr class="text-left" :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<span v-text="col.label"></span>
|
||||
</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"
|
||||
class="q-ml-sm"
|
||||
><q-tooltip>Shareable Page</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
size="xs"
|
||||
icon="visibility"
|
||||
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
|
||||
class="q-ml-sm"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
><q-tooltip>View Link</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
class="q-ml-sm"
|
||||
>
|
||||
<q-tooltip>Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deletePayLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
class="q-ml-sm"
|
||||
><q-tooltip>Delete</q-tooltip></q-btn
|
||||
>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
v-text="col.value"
|
||||
></q-td>
|
||||
<q-td>
|
||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||
<q-tooltip
|
||||
>Webhook to <span v-text="props.row.webhook_url"></span
|
||||
></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 '<span
|
||||
v-text="props.row.success_text"
|
||||
></span
|
||||
>'
|
||||
<span v-if="props.row.success_url"
|
||||
>and URL '<span v-text="props.row.success_url"></span
|
||||
>'</span
|
||||
>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.comment_chars > 0"
|
||||
size="14px"
|
||||
name="insert_comment"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row.comment_chars"></span>-char
|
||||
comment allowed
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</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">LNURL-pay extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
<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 <span v-text="baseUrl"></span> -H "X-Api-Key:
|
||||
<span v-text="g.user.wallets[0].inkey"></span>"
|
||||
</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
|
||||
<span v-text="baseUrl + '/<pay_id>'"></span>
|
||||
-H "X-Api-Key:
|
||||
<span v-text="g.user.wallets[0].inkey"></span>"
|
||||
</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> "username":
|
||||
<string> }</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 <span v-text="baseUrl"></span> -d
|
||||
'{"description": <string>, "amount":
|
||||
<integer>, "max": <integer>, "min":
|
||||
<integer>, "comment_chars": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key:
|
||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
||||
</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
|
||||
<span v-text="baseUrl + '/<pay_id>'"></span>
|
||||
-d '{"description": <string>, "amount":
|
||||
<integer>}' -H "Content-type: application/json" -H
|
||||
"X-Api-Key:
|
||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
||||
</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
|
||||
<span v-text="baseUrl + '/<pay_id>'"></span>
|
||||
-H "X-Api-Key:
|
||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<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>
|
||||
</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">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.username"
|
||||
type="text"
|
||||
label="Lightning Address"
|
||||
@input="
|
||||
formDialog.data.username =
|
||||
formDialog.data.username.toLowerCase()
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="col" style="flex: 0 0 auto; margin-top: 10px">
|
||||
<span class="label"> @ </span>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.domain"
|
||||
type="text"
|
||||
:label="domain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm q-mx-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 *'"
|
||||
:hint="
|
||||
formDialog.data.currency &&
|
||||
fiatRates[formDialog.data.currency] &&
|
||||
formDialog.data.min
|
||||
? `approx. ${parseInt(Math.round(formDialog.data.min * fiatRates[formDialog.data.currency]))} sat`
|
||||
: ''
|
||||
"
|
||||
></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 *"
|
||||
:hint="
|
||||
formDialog.data.currency &&
|
||||
fiatRates[formDialog.data.currency] &&
|
||||
formDialog.data.max
|
||||
? `approx. ${parseInt(Math.round(formDialog.data.max * fiatRates[formDialog.data.currency]))} sat`
|
||||
: ''
|
||||
"
|
||||
></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="g.allowedCurrencies || g.currencies"
|
||||
v-model="formDialog.data.currency"
|
||||
:display-value="formDialog.data.currency || 'satoshis'"
|
||||
label="Currency"
|
||||
:hint="
|
||||
'Converted to satoshis at each payment. ' +
|
||||
(formDialog.data.currency &&
|
||||
fiatRates[formDialog.data.currency]
|
||||
? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat`
|
||||
: '')
|
||||
"
|
||||
@input="updateFiatRate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
icon="settings"
|
||||
label="Advanced options"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
LUD-11: Disposable and storeable payRequests.
|
||||
</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-checkbox
|
||||
dense
|
||||
:toggle-indeterminate="false"
|
||||
v-model="formDialog.data.disposable"
|
||||
label="If enabled, the LNURL will not be stored (default)."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.comment_chars"
|
||||
type="number"
|
||||
label="Comment maximum characters"
|
||||
hint="Allow the payer to attach a comment."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" v-if="formDialog.data.webhook_url">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
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>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.success_url"
|
||||
type="text"
|
||||
label="Success URL (optional)"
|
||||
hint="Link will be shown to the sender after a successful payment."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Nostr</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-checkbox
|
||||
:toggle-indeterminate="false"
|
||||
dense
|
||||
v-model="formDialog.data.zaps"
|
||||
label="Enable nostr zaps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<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">
|
||||
<lnbits-qrcode-lnurl :url="activeUrl" :nfc="true"></lnbits-qrcode-lnurl>
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> <span v-text="qrCodeDialog.data.id"></span><br />
|
||||
<strong>Amount:</strong>
|
||||
<span v-text="qrCodeDialog.data.amount"></span><br />
|
||||
|
||||
<span v-if="qrCodeDialog.data.currency"
|
||||
><strong
|
||||
><span v-text="qrCodeDialog.data.currency"></span> price:</strong
|
||||
>
|
||||
<span
|
||||
v-if="fiatRates[qrCodeDialog.data.currency]"
|
||||
v-text="fiatRates[qrCodeDialog.data.currency] + 'sat'"
|
||||
></span>
|
||||
<span v-else>Loading...</span>
|
||||
<br
|
||||
/></span>
|
||||
<strong>Accepts comments:</strong>
|
||||
<span v-text="qrCodeDialog.data.comments"></span><br />
|
||||
<strong>Dispatches webhook to:</strong>
|
||||
<span v-text="qrCodeDialog.data.webhook"></span><br />
|
||||
<strong>On success:</strong>
|
||||
<span v-text="qrCodeDialog.data.success"></span><br />
|
||||
<span v-if="qrCodeDialog.data.username">
|
||||
<strong>Lightning Address: </strong>
|
||||
<span v-text="lnaddress(qrCodeDialog.data)"></span>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="text-grey cursor-pointer q-ml-sm"
|
||||
@click="utils.copyText(lnaddress(qrCodeDialog.data))"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="text-grey cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<lnbits-qrcode
|
||||
class="q-pa-md"
|
||||
:value="lnaddress(qrCodeDialog.data)"
|
||||
:show-buttons="false"
|
||||
></lnbits-qrcode>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
<br />
|
||||
</span>
|
||||
</p>
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="link"
|
||||
@click="
|
||||
utils.copyText(
|
||||
qrCodeDialog.data.pay_url,
|
||||
'Link copied to clipboard!'
|
||||
)
|
||||
"
|
||||
><q-tooltip>Copy sharable link</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>
|
||||
</template>
|
||||
|
|
@ -1,16 +1,32 @@
|
|||
window.PageLnurlp = {
|
||||
template: '#page-lnurlp',
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
const locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
const mapPayLink = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = LNbits.utils.formatDate(obj.time)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
computed: {
|
||||
baseUrl() {
|
||||
return window.location.origin + '/lnurlp/api/v1/links'
|
||||
},
|
||||
endpoint() {
|
||||
endpoint: function () {
|
||||
return `/lnurlp/api/v1/settings?usr=${this.g.user.id}`
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeUrl: '',
|
||||
settings: [
|
||||
{
|
||||
type: 'str',
|
||||
|
|
@ -19,51 +35,11 @@ window.PageLnurlp = {
|
|||
}
|
||||
],
|
||||
domain: window.location.host,
|
||||
currencies: [],
|
||||
fiatRates: {},
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'created_at',
|
||||
label: 'Created',
|
||||
align: 'left',
|
||||
field: 'created_at',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
align: 'left',
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
align: 'left',
|
||||
format: (_, row) => {
|
||||
const min = row.min
|
||||
const max = row.max
|
||||
if (min === max) return `${min}`
|
||||
return `${min} - ${max}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
label: 'Currency',
|
||||
align: 'left',
|
||||
field: 'currency',
|
||||
format: val => val ?? 'sat'
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
align: 'left',
|
||||
field: 'username',
|
||||
sortable: true,
|
||||
format: val => val ?? 'None',
|
||||
classes: val => (val ? 'text-normal' : 'text-grey')
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
|
|
@ -73,7 +49,6 @@ window.PageLnurlp = {
|
|||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {
|
||||
disposable: true,
|
||||
zaps: false
|
||||
}
|
||||
},
|
||||
|
|
@ -84,28 +59,6 @@ window.PageLnurlp = {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
lnaddress(link) {
|
||||
const domain = link.domain || window.location.host
|
||||
return `${link.username}@${domain}`
|
||||
},
|
||||
mapPayLink(obj) {
|
||||
const locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
obj._data = _.clone(obj)
|
||||
obj.created_at = LNbits.utils.formatDate(obj.created_at)
|
||||
obj.updated_at = LNbits.utils.formatDate(obj.updated_at)
|
||||
if (obj.currency) {
|
||||
obj.min = obj.min / obj.fiat_base_multiplier
|
||||
obj.max = obj.max / obj.fiat_base_multiplier
|
||||
}
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, 'link/', obj.id].join('')
|
||||
return obj
|
||||
},
|
||||
getPayLinks() {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
|
@ -114,9 +67,12 @@ window.PageLnurlp = {
|
|||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = response.data.map(this.mapPayLink)
|
||||
this.payLinks = response.data.map(mapPayLink)
|
||||
})
|
||||
.catch(err => {
|
||||
clearInterval(this.checker)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
closeFormDialog() {
|
||||
this.resetFormData()
|
||||
|
|
@ -144,20 +100,17 @@ window.PageLnurlp = {
|
|||
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||
: 'do nothing',
|
||||
lnurl: link.lnurl,
|
||||
domain: link.domain,
|
||||
pay_url: link.pay_url,
|
||||
print_url: link.print_url,
|
||||
username: link.username
|
||||
}
|
||||
const domain = link.domain || window.location.host
|
||||
this.activeUrl = `https://${domain}/lnurlp/${link.id}`
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
openUpdateDialog(linkId) {
|
||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.formDialog.data = {...link}
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
this.formDialog.fixedAmount =
|
||||
this.formDialog.data.min === this.formDialog.data.max
|
||||
|
|
@ -193,7 +146,7 @@ window.PageLnurlp = {
|
|||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(this.mapPayLink(response.data))
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
|
|
@ -225,7 +178,7 @@ window.PageLnurlp = {
|
|||
'/lnurlp/api/v1/links/' + linkId,
|
||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(() => {
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||
})
|
||||
.catch(err => {
|
||||
|
|
@ -235,18 +188,68 @@ window.PageLnurlp = {
|
|||
},
|
||||
updateFiatRate(currency) {
|
||||
LNbits.api
|
||||
.request('GET', '/api/v1/rate/' + currency, null)
|
||||
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
|
||||
.then(response => {
|
||||
this.fiatRates[currency] = response.data.rate
|
||||
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)
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.g.user.wallets?.length) {
|
||||
this.getPayLinks()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
[
|
||||
{
|
||||
"path": "/lnurlp/",
|
||||
"name": "PageLnurlp",
|
||||
"template": "/lnurlp/static/index.vue",
|
||||
"component": "/lnurlp/static/index.js"
|
||||
},
|
||||
{
|
||||
"path": "/lnurlp/link/:id",
|
||||
"name": "PageLnurlpPublic",
|
||||
"template": "/lnurlp/static/display.vue",
|
||||
"component": "/lnurlp/static/display.js"
|
||||
}
|
||||
]
|
||||
135
tasks.py
135
tasks.py
|
|
@ -1,21 +1,25 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from threading import Thread
|
||||
from typing import List
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from lnbits.core.crud import get_payment, update_payment
|
||||
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 loguru import logger
|
||||
from pynostr.event import Event
|
||||
from websocket import WebSocketApp
|
||||
|
||||
from .crud import get_or_create_lnurlp_settings, get_pay_link
|
||||
from .models import PayLink
|
||||
from .nostr.event import EncryptedDirectMessage
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "ext_lnurlp")
|
||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
|
|
@ -23,7 +27,10 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment):
|
||||
if not payment.extra or payment.extra.get("tag") != "lnurlp":
|
||||
if not payment.extra:
|
||||
return
|
||||
|
||||
if payment.extra.get("tag") != "lnurlp":
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
|
|
@ -40,16 +47,13 @@ async def on_invoice_paid(payment: Payment):
|
|||
logger.error(f"Invoice paid. But Pay link `{pay_link_id}` not found.")
|
||||
return
|
||||
|
||||
zap_receipt = None
|
||||
await send_webhook(payment, pay_link)
|
||||
|
||||
if pay_link.zaps:
|
||||
zap_receipt = await send_zap(payment)
|
||||
|
||||
await send_webhook(
|
||||
payment, pay_link, zap_receipt.to_message() if zap_receipt else None
|
||||
)
|
||||
await send_zap(payment)
|
||||
|
||||
|
||||
async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
|
||||
async def send_webhook(payment: Payment, pay_link: PayLink):
|
||||
if not pay_link.webhook_url:
|
||||
return
|
||||
|
||||
|
|
@ -61,27 +65,24 @@ async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
|
|||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"comment": payment.extra.get("comment") if payment.extra else None,
|
||||
"webhook_data": (
|
||||
payment.extra.get("webhook_data") if payment.extra else None
|
||||
),
|
||||
"comment": payment.extra.get("comment"),
|
||||
"webhook_data": payment.extra.get("webhook_data") or "",
|
||||
"lnurlp": pay_link.id,
|
||||
"body": (
|
||||
json.loads(pay_link.webhook_body)
|
||||
if pay_link.webhook_body
|
||||
else ""
|
||||
),
|
||||
"zap_receipt": zap_receipt or "",
|
||||
},
|
||||
headers=(
|
||||
json.loads(pay_link.webhook_headers)
|
||||
if pay_link.webhook_headers
|
||||
else None
|
||||
),
|
||||
timeout=6,
|
||||
timeout=40,
|
||||
)
|
||||
await mark_webhook_sent(
|
||||
payment.checking_id,
|
||||
payment.payment_hash,
|
||||
r.status_code,
|
||||
r.is_success,
|
||||
r.reason_phrase,
|
||||
|
|
@ -90,21 +91,22 @@ async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
|
|||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
await mark_webhook_sent(
|
||||
payment.checking_id, -1, False, "Unexpected Error", str(exc)
|
||||
payment.payment_hash, -1, False, "Unexpected Error", str(exc)
|
||||
)
|
||||
|
||||
|
||||
async def mark_webhook_sent(
|
||||
checking_id: str, status: int, is_success: bool, reason_phrase="", text=""
|
||||
payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
|
||||
) -> None:
|
||||
payment = await get_payment(checking_id)
|
||||
extra = payment.extra or {}
|
||||
extra["wh_status"] = status # keep for backwards compability
|
||||
extra["wh_success"] = is_success
|
||||
extra["wh_message"] = reason_phrase
|
||||
extra["wh_response"] = text
|
||||
payment.extra = extra
|
||||
await update_payment(payment)
|
||||
await update_payment_extra(
|
||||
payment_hash,
|
||||
{
|
||||
"wh_status": status, # keep for backwards compability
|
||||
"wh_success": is_success,
|
||||
"wh_message": reason_phrase,
|
||||
"wh_response": text,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# NIP-57 - load the zap request
|
||||
|
|
@ -120,7 +122,7 @@ async def send_zap(payment: Payment):
|
|||
return res[0] if res else None
|
||||
|
||||
tags = []
|
||||
for t in ["p", "e", "a"]:
|
||||
for t in ["p", "e"]:
|
||||
tag = get_tag(event_json, t)
|
||||
if tag:
|
||||
tags.append([t, tag[0]])
|
||||
|
|
@ -129,49 +131,52 @@ async def send_zap(payment: Payment):
|
|||
|
||||
pubkey = next((pk[1] for pk in tags if pk[0] == "p"), None)
|
||||
assert pubkey, "Cannot create zap receipt. Recepient pubkey is missing."
|
||||
zap_receipt = Event(
|
||||
zap_receipt = EncryptedDirectMessage(
|
||||
kind=9735,
|
||||
recipient_pubkey=pubkey,
|
||||
tags=tags,
|
||||
content="",
|
||||
content=payment.extra.get("comment") or "",
|
||||
cleartext_content=payment.extra.get("comment") or "",
|
||||
)
|
||||
|
||||
settings = await get_or_create_lnurlp_settings()
|
||||
zap_receipt.sign(settings.private_key.hex())
|
||||
settings.private_key.sign_event(zap_receipt)
|
||||
|
||||
async def send_to_relay(relay_url: str, event_message: str):
|
||||
"""Helper function to send an event to a single relay."""
|
||||
try:
|
||||
async with websockets.connect(relay_url, open_timeout=5) as websocket:
|
||||
logger.debug(f"Sending zap to {relay_url}")
|
||||
await websocket.send(event_message)
|
||||
response = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
relay_response = json.loads(response)
|
||||
if relay_response[0] != "OK" or not relay_response[2]:
|
||||
logger.debug(
|
||||
f"Relay did not acknowledge zap receipt: {relay_response}"
|
||||
)
|
||||
return
|
||||
logger.debug(f"Zap sent to {relay_url} successfully")
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug(f"Relay did not acknowledge zap receipt: {relay_url}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send zap to {relay_url}: {e}")
|
||||
def send(relay):
|
||||
def send_event(_):
|
||||
logger.debug(f"Sending zap to {ws.url}")
|
||||
ws.send(zap_receipt.to_message())
|
||||
time.sleep(2)
|
||||
ws.close()
|
||||
|
||||
# Get relays from the zap request, with a reasonable limit
|
||||
ws = WebSocketApp(relay, on_open=send_event)
|
||||
wst = Thread(target=ws.run_forever, name=f"LNURL zap {relay}")
|
||||
wst.daemon = True
|
||||
wst.start()
|
||||
return ws, wst
|
||||
|
||||
# list of all websockets
|
||||
wss: List[WebSocketApp] = []
|
||||
# list of all threads for these websockets
|
||||
wsts: List[Thread] = []
|
||||
|
||||
# # send zap via nostrclient
|
||||
# ws, wst = send(f"wss://localhost:{settings.port}/nostrclient/api/v1/relay")
|
||||
# wss += [ws]
|
||||
# wsts += [wst]
|
||||
|
||||
# send zap receipt to relays in zap request
|
||||
relays = get_tag(event_json, "relays")
|
||||
if not relays:
|
||||
return zap_receipt
|
||||
|
||||
if relays:
|
||||
if len(relays) > 50:
|
||||
relays = relays[:50]
|
||||
for r in relays:
|
||||
ws, wst = send(r)
|
||||
wss += [ws]
|
||||
wsts += [wst]
|
||||
|
||||
# Create a list of tasks to run concurrently
|
||||
|
||||
# Run all tasks concurrently. This is a "fire-and-forget" approach.
|
||||
# We don't need to wait for all of them to complete here.
|
||||
_ = [
|
||||
asyncio.create_task(send_to_relay(relay, zap_receipt.to_message()))
|
||||
for relay in relays
|
||||
]
|
||||
|
||||
return zap_receipt
|
||||
await asyncio.sleep(10)
|
||||
for ws, wst in zip(wss, wsts):
|
||||
logger.debug(f"Closing websocket {ws.url}")
|
||||
ws.close()
|
||||
wst.join()
|
||||
|
|
|
|||
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:
|
||||
<span v-text="g.user.wallets[0].inkey"></span> "
|
||||
</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: <span v-text="g.user.wallets[0].inkey"></span>"
|
||||
</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> "username": <string> }</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:
|
||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
||||
</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:
|
||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
||||
</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:
|
||||
<span v-text="g.user.wallets[0].adminkey"></span>"
|
||||
</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>
|
||||
48
templates/lnurlp/display.html
Normal file
48
templates/lnurlp/display.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{% 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">
|
||||
<lnbits-qrcode value="lightning:{{ lnurl }}"></lnbits-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>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin]
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
425
templates/lnurlp/index.html
Normal file
425
templates/lnurlp/index.html
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
{% 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
|
||||
>
|
||||
<lnbits-extension-settings-btn-dialog
|
||||
v-if="this.g.user.admin"
|
||||
:endpoint="endpoint"
|
||||
:options="settings"
|
||||
/>
|
||||
</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
|
||||
:rows="payLinks"
|
||||
row-key="id"
|
||||
v-model:pagination="payLinksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr class="text-left" :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>Username</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"
|
||||
class="q-ml-sm"
|
||||
><q-tooltip>Shareable Page</q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="visibility"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
class="q-ml-sm"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
><q-tooltip>View Link</q-tooltip></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
|
||||
auto-width
|
||||
:class="(props.row.username) ? 'text-normal' : 'text-grey'"
|
||||
>{{ props.row.username || 'None' }}</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-tooltip>Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deletePayLink(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
><q-tooltip>Delete</q-tooltip></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">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.username"
|
||||
type="text"
|
||||
label="Lightning Address"
|
||||
@input="formDialog.data.username = formDialog.data.username.toLowerCase()"
|
||||
/>
|
||||
</div>
|
||||
<div class="col" style="margin-top: 10px">
|
||||
<span class="label">
|
||||
@ {% raw %} {{ domain }} {% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm q-mx-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="'Converted to satoshis at each payment. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
|
||||
@input="updateFiatRate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<q-expansion-item
|
||||
group="advanced"
|
||||
icon="settings"
|
||||
label="Advanced options"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.comment_chars"
|
||||
type="number"
|
||||
label="Comment maximum characters"
|
||||
hint="Allow the payer to attach a comment."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" v-if="formDialog.data.webhook_url">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
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>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.success_url"
|
||||
type="text"
|
||||
label="Success URL (optional)"
|
||||
hint="Link will be shown to the sender after a successful payment."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Nostr</h5>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-checkbox
|
||||
:toggle-indeterminate="false"
|
||||
dense
|
||||
v-model="formDialog.data.zaps"
|
||||
label="Enable nostr zaps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<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">
|
||||
<lnbits-qrcode :value="'lightning:' + qrCodeDialog.data.lnurl">
|
||||
</lnbits-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 />
|
||||
<span v-if="qrCodeDialog.data.username">
|
||||
<strong>Lightning Address: </strong>
|
||||
{{ qrCodeDialog.data.username }}@{{ domain }}
|
||||
<br />
|
||||
</span>
|
||||
</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 %}
|
||||
25
templates/lnurlp/print_qr.html
Normal file
25
templates/lnurlp/print_qr.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "print.html" %} {% block page %}
|
||||
<div class="row justify-center">
|
||||
<div class="qr">
|
||||
<lnbits-qrcode value="lightning:{{ lnurl }}"></lnbits-qrcode>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block styles %}
|
||||
<style>
|
||||
.qr {
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
created: function () {
|
||||
window.print()
|
||||
},
|
||||
data: function () {
|
||||
return {width: window.innerWidth * 0.5}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"""
|
||||
Nostr-transport RPC handlers for the lnurlp (LNURL-pay) extension.
|
||||
|
||||
Exposes the same CRUD surface that `views_api.py` exposes via HTTP, but
|
||||
encrypted over kind-21000 events through the LNbits nostr transport.
|
||||
Mirrors the withdraw extension's `transport_rpcs.py`; both extensions
|
||||
hang their handlers off the core dispatcher via their `*_start()` hook.
|
||||
|
||||
Auth model (set by the registrations in `__init__.py:lnurlp_start`):
|
||||
- *_create / *_get / *_update / *_delete → AUTH_WALLET. The transport
|
||||
resolves the caller's pubkey to a wallet (admin access) before
|
||||
invoking the handler, so we know `auth.wallet.id` and `auth.wallet.user`.
|
||||
- *_list → AUTH_ACCOUNT. The caller may list links across all wallets
|
||||
they own, optionally narrowed by `request.wallet_id`.
|
||||
|
||||
Ownership: *_get / *_update / *_delete also verify the link's stored
|
||||
`wallet` field matches the caller's wallet id — defense in depth, since
|
||||
a malicious client could otherwise probe link metadata they don't own.
|
||||
|
||||
`resolve_lnurlp_owner` is registered with the core subscription module
|
||||
under tag `"lnurlp"` (default link_extra_key `"link"` — that's where
|
||||
`views_lnurl.py:86` stamps the link id on settlement). That lets clients
|
||||
call `subscribe_payments({tag:"lnurlp", link_id:...})` and stream real-
|
||||
time pay events without polling, with ownership enforced server-side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.core.models.wallets import WalletTypeInfo
|
||||
from lnbits.core.services.nostr_transport.models import NostrRpcRequest
|
||||
|
||||
from .crud import (
|
||||
create_pay_link,
|
||||
delete_pay_link,
|
||||
get_pay_link,
|
||||
get_pay_links,
|
||||
update_pay_link,
|
||||
)
|
||||
from .models import CreatePayLinkData
|
||||
|
||||
|
||||
async def handle_lnurlp_create(
|
||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||
) -> dict:
|
||||
body = request.body or {}
|
||||
body["wallet"] = auth.wallet.id # always create under the calling wallet
|
||||
data = CreatePayLinkData(**body)
|
||||
link = await create_pay_link(data)
|
||||
return _to_dict(link)
|
||||
|
||||
|
||||
async def handle_lnurlp_get(
|
||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||
) -> dict:
|
||||
link_id = _require_id(request)
|
||||
link = await _require_owned_link(link_id, auth.wallet.id)
|
||||
return _to_dict(link)
|
||||
|
||||
|
||||
async def handle_lnurlp_list(
|
||||
auth: Account, request: NostrRpcRequest
|
||||
) -> list[dict]:
|
||||
"""List PayLinks across all wallets owned by the calling account.
|
||||
If `request.wallet_id` is set and is one of those wallets, narrow to
|
||||
just that wallet."""
|
||||
wallets = await get_wallets(auth.id)
|
||||
wallet_ids = [w.id for w in wallets]
|
||||
if not wallet_ids:
|
||||
return []
|
||||
if request.wallet_id and request.wallet_id in wallet_ids:
|
||||
wallet_ids = [request.wallet_id]
|
||||
links = await get_pay_links(wallet_ids)
|
||||
return [_to_dict(link) for link in links]
|
||||
|
||||
|
||||
async def handle_lnurlp_update(
|
||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||
) -> dict:
|
||||
link_id = _require_id(request)
|
||||
link = await _require_owned_link(link_id, auth.wallet.id)
|
||||
body = request.body or {}
|
||||
# Only patchable fields. Identity / counter fields (id, wallet,
|
||||
# served_meta, served_pr, created_at) are not client-mutable.
|
||||
_MUTABLE = {
|
||||
"description",
|
||||
"min",
|
||||
"max",
|
||||
"comment_chars",
|
||||
"currency",
|
||||
"webhook_url",
|
||||
"webhook_headers",
|
||||
"webhook_body",
|
||||
"success_text",
|
||||
"success_url",
|
||||
"fiat_base_multiplier",
|
||||
"username",
|
||||
"zaps",
|
||||
"disposable",
|
||||
"domain",
|
||||
}
|
||||
for k, v in body.items():
|
||||
if k in _MUTABLE:
|
||||
setattr(link, k, v)
|
||||
updated = await update_pay_link(link)
|
||||
return _to_dict(updated)
|
||||
|
||||
|
||||
async def handle_lnurlp_delete(
|
||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||
) -> dict:
|
||||
link_id = _require_id(request)
|
||||
await _require_owned_link(link_id, auth.wallet.id)
|
||||
await delete_pay_link(link_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
async def resolve_lnurlp_owner(link_id: str) -> str | None:
|
||||
"""For the core subscription module: link_id -> wallet_id (or None)."""
|
||||
link = await get_pay_link(link_id)
|
||||
return link.wallet if link else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_id(request: NostrRpcRequest) -> str:
|
||||
body = request.body or {}
|
||||
link_id = body.get("id")
|
||||
if not link_id:
|
||||
raise ValueError("lnurlp: body.id is required")
|
||||
return str(link_id)
|
||||
|
||||
|
||||
async def _require_owned_link(link_id: str, wallet_id: str):
|
||||
link = await get_pay_link(link_id)
|
||||
if link is None:
|
||||
raise ValueError(f"lnurlp: link not found: {link_id}")
|
||||
if link.wallet != wallet_id:
|
||||
raise PermissionError(
|
||||
"lnurlp: link does not belong to caller's wallet"
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
def _to_dict(link) -> dict:
|
||||
import json
|
||||
return json.loads(link.json())
|
||||
50
views.py
50
views.py
|
|
@ -1,17 +1,45 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from lnbits.core.views.generic import index, index_public
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.helpers import template_renderer
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from .crud import get_pay_link
|
||||
|
||||
lnurlp_generic_router = APIRouter()
|
||||
|
||||
lnurlp_generic_router.add_api_route(
|
||||
"/", methods=["GET"], endpoint=index, dependencies=[Depends(check_user_exists)]
|
||||
)
|
||||
|
||||
lnurlp_generic_router.add_api_route(
|
||||
"/link/{link_id}", methods=["GET"], endpoint=index_public
|
||||
)
|
||||
def lnurlp_renderer():
|
||||
return template_renderer(["lnurlp/templates"])
|
||||
|
||||
lnurlp_generic_router.add_api_route(
|
||||
"/print/{link_id}", methods=["GET"], endpoint=index_public
|
||||
)
|
||||
|
||||
@lnurlp_generic_router.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.json()}
|
||||
)
|
||||
|
||||
|
||||
@lnurlp_generic_router.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_generic_router.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)
|
||||
|
|
|
|||
106
views_api.py
106
views_api.py
|
|
@ -1,16 +1,19 @@
|
|||
import json
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from lnbits.core.crud import get_user, get_wallet
|
||||
from lnbits.core.models import SimpleStatus, WalletTypeInfo
|
||||
from lnbits.core.models import WalletTypeInfo
|
||||
from lnbits.decorators import (
|
||||
check_admin,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnurl import InvalidUrl
|
||||
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from .crud import (
|
||||
create_pay_link,
|
||||
|
|
@ -23,28 +26,15 @@ from .crud import (
|
|||
update_lnurlp_settings,
|
||||
update_pay_link,
|
||||
)
|
||||
from .helpers import lnurl_encode_link, parse_nostr_private_key
|
||||
from .models import CreatePayLinkData, LnurlpSettings, PayLink, PublicPayLink
|
||||
from .helpers import parse_nostr_private_key
|
||||
from .models import CreatePayLinkData, LnurlpSettings
|
||||
|
||||
lnurlp_api_router = APIRouter()
|
||||
|
||||
|
||||
def check_lnurl_encode(req: Request, link: PayLink) -> str:
|
||||
try:
|
||||
return lnurl_encode_link(req, link.id, link.domain)
|
||||
except InvalidUrl as exc:
|
||||
raise HTTPException(
|
||||
detail=(
|
||||
f"Invalid URL for LNURL encoding: `{req.base_url}`. "
|
||||
"Check proxy settings."
|
||||
),
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
detail="Error encoding LNURL.",
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
) from exc
|
||||
@lnurlp_api_router.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
@lnurlp_api_router.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||
|
|
@ -52,22 +42,33 @@ async def api_links(
|
|||
req: Request,
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
all_wallets: bool = Query(False),
|
||||
) -> list[PayLink]:
|
||||
):
|
||||
wallet_ids = [key_info.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
user = await get_user(key_info.wallet.user)
|
||||
wallet_ids = user.wallet_ids if user else []
|
||||
|
||||
links = await get_pay_links(wallet_ids)
|
||||
for link in links:
|
||||
link.lnurl = check_lnurl_encode(req, link)
|
||||
return links
|
||||
try:
|
||||
return [
|
||||
{**link.dict(), "lnurl": link.lnurl(req)}
|
||||
for link in await get_pay_links(wallet_ids)
|
||||
]
|
||||
|
||||
except LnurlInvalidUrl as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
||||
detail=(
|
||||
"LNURLs need to be delivered over a publicly "
|
||||
"accessible `https` domain or Tor onion."
|
||||
),
|
||||
) from exc
|
||||
|
||||
|
||||
@lnurlp_api_router.get("/api/v1/links/{link_id}")
|
||||
@lnurlp_api_router.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_retrieve(
|
||||
req: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
||||
) -> PayLink:
|
||||
r: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
if not link:
|
||||
|
|
@ -85,19 +86,7 @@ async def api_link_retrieve(
|
|||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
link.lnurl = check_lnurl_encode(req, link)
|
||||
return link
|
||||
|
||||
|
||||
@lnurlp_api_router.get("/api/v1/links/public/{link_id}", response_model=PublicPayLink)
|
||||
async def api_link_public_retrieve(req: Request, link_id: str) -> PayLink:
|
||||
link = await get_pay_link(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
link.lnurl = lnurl_encode_link(req, link.id, link.domain)
|
||||
return link
|
||||
return {**link.dict(), **{"lnurl": link.lnurl(r)}}
|
||||
|
||||
|
||||
async def check_username_exists(username: str):
|
||||
|
|
@ -112,18 +101,19 @@ async def check_username_exists(username: str):
|
|||
@lnurlp_api_router.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||
@lnurlp_api_router.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_create_or_update(
|
||||
req: Request,
|
||||
data: CreatePayLinkData,
|
||||
link_id: str | None = None,
|
||||
request: Request,
|
||||
link_id: Optional[str] = None,
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> PayLink:
|
||||
):
|
||||
if data.min > data.max:
|
||||
raise HTTPException(
|
||||
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if not data.currency:
|
||||
if round(data.min) != data.min or round(data.max) != data.max or data.min < 1:
|
||||
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
|
||||
)
|
||||
|
|
@ -179,7 +169,7 @@ async def api_link_create_or_update(
|
|||
detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
# admins are allowed to create/edit paylinks belonging to regular users
|
||||
# admins are allowed to create/edit paylinks beloging to regular users
|
||||
user = await get_user(key_info.wallet.user)
|
||||
admin_user = user.admin if user else False
|
||||
if not admin_user and new_wallet.user != key_info.wallet.user:
|
||||
|
|
@ -208,14 +198,14 @@ async def api_link_create_or_update(
|
|||
|
||||
link = await create_pay_link(data)
|
||||
|
||||
link.lnurl = check_lnurl_encode(req, link)
|
||||
return link
|
||||
assert link
|
||||
return {**link.dict(), "lnurl": link.lnurl(request)}
|
||||
|
||||
|
||||
@lnurlp_api_router.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_delete(
|
||||
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> SimpleStatus:
|
||||
):
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
if not link:
|
||||
|
|
@ -223,7 +213,7 @@ async def api_link_delete(
|
|||
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
# admins are allowed to delete paylinks belonging to regular users
|
||||
# admins are allowed to delete paylinks beloging to regular users
|
||||
user = await get_user(key_info.wallet.user)
|
||||
admin_user = user.admin if user else False
|
||||
if not admin_user and link.wallet != key_info.wallet.id:
|
||||
|
|
@ -232,7 +222,17 @@ async def api_link_delete(
|
|||
)
|
||||
|
||||
await delete_pay_link(link_id)
|
||||
return SimpleStatus(success=True, message="Deleted Pay link")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@lnurlp_api_router.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}
|
||||
|
||||
|
||||
@lnurlp_api_router.get("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||
|
|
|
|||
134
views_lnurl.py
134
views_lnurl.py
|
|
@ -1,21 +1,18 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
||||
from lnurl import (
|
||||
CallbackUrl,
|
||||
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
||||
from lnurl.models import MessageAction, UrlAction
|
||||
from lnurl.types import (
|
||||
ClearnetUrl,
|
||||
DebugUrl,
|
||||
LightningInvoice,
|
||||
LnurlErrorResponse,
|
||||
LnurlPayActionResponse,
|
||||
LnurlPayMetadata,
|
||||
LnurlPayResponse,
|
||||
LnurlPaySuccessActionTag,
|
||||
Max144Str,
|
||||
MessageAction,
|
||||
MilliSatoshi,
|
||||
UrlAction,
|
||||
OnionUrl,
|
||||
)
|
||||
from pydantic import parse_obj_as
|
||||
|
||||
|
|
@ -39,7 +36,7 @@ async def api_lnurl_callback(
|
|||
link_id: str,
|
||||
amount: int = Query(...),
|
||||
webhook_data: str = Query(None),
|
||||
) -> LnurlErrorResponse | LnurlPayActionResponse:
|
||||
):
|
||||
link = await get_pay_link(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
|
|
@ -47,30 +44,28 @@ async def api_lnurl_callback(
|
|||
)
|
||||
link.served_pr = 1
|
||||
await update_pay_link(link)
|
||||
minimum = link.min
|
||||
mininum = link.min
|
||||
maximum = link.max
|
||||
|
||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||
if link.currency and link.fiat_base_multiplier:
|
||||
link.min = link.min / link.fiat_base_multiplier
|
||||
link.max = link.max / link.fiat_base_multiplier
|
||||
if link.currency:
|
||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||
minimum = rate * 995 * link.min
|
||||
mininum = rate * 995 * link.min
|
||||
maximum = rate * 1010 * link.max
|
||||
else:
|
||||
minimum = link.min * 1000
|
||||
mininum = link.min * 1000
|
||||
maximum = link.max * 1000
|
||||
|
||||
amount = amount
|
||||
if amount < minimum:
|
||||
if amount < mininum:
|
||||
return LnurlErrorResponse(
|
||||
reason=f"Amount {amount} is smaller than minimum {minimum}."
|
||||
)
|
||||
reason=f"Amount {amount} is smaller than minimum {min}."
|
||||
).dict()
|
||||
|
||||
elif amount > maximum:
|
||||
return LnurlErrorResponse(
|
||||
reason=f"Amount {amount} is greater than maximum {maximum}."
|
||||
)
|
||||
reason=f"Amount {amount} is greater than maximum {max}."
|
||||
).dict()
|
||||
|
||||
comment = request.query_params.get("comment")
|
||||
if len(comment or "") > link.comment_chars:
|
||||
|
|
@ -79,7 +74,11 @@ async def api_lnurl_callback(
|
|||
f"Got a comment with {len(comment or '')} characters, "
|
||||
f"but can only accept {link.comment_chars}"
|
||||
)
|
||||
)
|
||||
).dict()
|
||||
|
||||
# for lnaddress, we have to set this otherwise
|
||||
# the metadata won't have the identifier
|
||||
link.domain = request.url.netloc
|
||||
|
||||
extra = {
|
||||
"tag": "lnurlp",
|
||||
|
|
@ -98,60 +97,50 @@ async def api_lnurl_callback(
|
|||
if nostr:
|
||||
extra["nostr"] = nostr # put it here for later publishing in tasks.py
|
||||
|
||||
if link.username:
|
||||
identifier = f"{link.username}@{link.domain or request.url.netloc}"
|
||||
text = f"Payment to {link.username}"
|
||||
_metadata = [["text/plain", text], ["text/identifier", identifier]]
|
||||
extra["lnaddress"] = identifier
|
||||
else:
|
||||
_metadata = [["text/plain", link.description]]
|
||||
|
||||
metadata = LnurlPayMetadata(json.dumps(_metadata))
|
||||
if link.username and link.domain:
|
||||
extra["lnaddress"] = f"{link.username}@{link.domain}"
|
||||
|
||||
# we take the zap request as the description instead of the metadata if present
|
||||
unhashed_description = nostr.encode() if nostr else metadata.encode()
|
||||
unhashed_description = nostr.encode() if nostr else link.lnurlpay_metadata.encode()
|
||||
|
||||
payment = await create_invoice(
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount / 1000),
|
||||
memo=link.description,
|
||||
unhashed_description=unhashed_description,
|
||||
extra=extra,
|
||||
)
|
||||
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11))
|
||||
|
||||
action: Optional[Union[MessageAction, UrlAction]] = None
|
||||
if link.success_url:
|
||||
url = parse_obj_as(CallbackUrl, str(link.success_url))
|
||||
text = link.success_text or f"Link to {link.success_url}"
|
||||
desc = parse_obj_as(Max144Str, text)
|
||||
action = UrlAction(tag=LnurlPaySuccessActionTag.url, url=url, description=desc)
|
||||
return LnurlPayActionResponse(
|
||||
pr=invoice, successAction=action, disposable=link.disposable
|
||||
url = parse_obj_as(
|
||||
Union[DebugUrl, OnionUrl, ClearnetUrl], # type: ignore
|
||||
str(link.success_url),
|
||||
)
|
||||
|
||||
if link.success_text:
|
||||
desc = parse_obj_as(Max144Str, link.success_text)
|
||||
action = UrlAction(url=url, description=desc)
|
||||
elif link.success_text:
|
||||
message = parse_obj_as(Max144Str, link.success_text)
|
||||
return LnurlPayActionResponse(
|
||||
pr=invoice,
|
||||
successAction=MessageAction(message=message),
|
||||
disposable=link.disposable,
|
||||
)
|
||||
action = MessageAction(message=message)
|
||||
|
||||
return LnurlPayActionResponse(pr=invoice, disposable=link.disposable)
|
||||
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment_request))
|
||||
resp = LnurlPayActionResponse(pr=invoice, successAction=action, routes=[])
|
||||
return resp.dict()
|
||||
|
||||
|
||||
@lnurlp_lnurl_router.get(
|
||||
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
|
||||
status_code=HTTPStatus.OK,
|
||||
name="lnurlp.api_lnurl_response.deprecated",
|
||||
deprecated=True,
|
||||
)
|
||||
@lnurlp_lnurl_router.get(
|
||||
"/{link_id}",
|
||||
status_code=HTTPStatus.OK,
|
||||
name="lnurlp.api_lnurl_response",
|
||||
)
|
||||
async def api_lnurl_response(
|
||||
request: Request, link_id: str, webhook_data: str | None = Query(None)
|
||||
) -> LnurlPayResponse:
|
||||
request: Request, link_id: str, webhook_data: Optional[str] = Query(None)
|
||||
):
|
||||
link = await get_pay_link(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
|
|
@ -161,48 +150,37 @@ async def api_lnurl_response(
|
|||
await update_pay_link(link)
|
||||
|
||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||
|
||||
if link.currency and link.fiat_base_multiplier:
|
||||
link.min = link.min / link.fiat_base_multiplier
|
||||
link.max = link.max / link.fiat_base_multiplier
|
||||
|
||||
url = request.url_for("lnurlp.api_lnurl_callback", link_id=link.id)
|
||||
if webhook_data:
|
||||
url = url.include_query_params(webhook_data=webhook_data)
|
||||
|
||||
callback_url = parse_obj_as(CallbackUrl, str(url))
|
||||
link.domain = request.url.netloc
|
||||
callback_url = parse_obj_as(
|
||||
Union[DebugUrl, OnionUrl, ClearnetUrl], # type: ignore
|
||||
str(url),
|
||||
)
|
||||
|
||||
if link.username:
|
||||
identifier = f"{link.username}@{link.domain or request.url.netloc}"
|
||||
text = f"Payment to {link.username}"
|
||||
metadata = [["text/plain", text], ["text/identifier", identifier]]
|
||||
else:
|
||||
metadata = [["text/plain", link.description]]
|
||||
|
||||
res = LnurlPayResponse(
|
||||
resp = LnurlPayResponse(
|
||||
callback=callback_url,
|
||||
minSendable=MilliSatoshi(round(link.min * rate) * 1000),
|
||||
maxSendable=MilliSatoshi(round(link.max * rate) * 1000),
|
||||
metadata=LnurlPayMetadata(json.dumps(metadata)),
|
||||
metadata=link.lnurlpay_metadata,
|
||||
)
|
||||
params = resp.dict()
|
||||
|
||||
if link.comment_chars > 0:
|
||||
res.commentAllowed = link.comment_chars
|
||||
params["commentAllowed"] = link.comment_chars
|
||||
|
||||
if link.zaps:
|
||||
settings = await get_or_create_lnurlp_settings()
|
||||
res.allowsNostr = True
|
||||
res.nostrPubkey = settings.public_key
|
||||
|
||||
return res
|
||||
params["allowsNostr"] = True
|
||||
params["nostrPubkey"] = settings.public_key
|
||||
return params
|
||||
|
||||
|
||||
# redirected from /.well-known/lnurlp
|
||||
@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
|
||||
async def lnaddress(
|
||||
username: str, request: Request
|
||||
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||
async def lnaddress(username: str, request: Request):
|
||||
address_data = await get_address_data(username)
|
||||
if not address_data:
|
||||
return LnurlErrorResponse(reason="Lightning address not found.")
|
||||
return await api_lnurl_response(request, address_data.id)
|
||||
assert address_data, "User not found"
|
||||
return await api_lnurl_response(request, address_data.id, webhook_data=None)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue