Compare commits
152 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31cf2eb164 | |||
|
|
d299e15c2f |
||
|
|
dc37e259ba |
||
|
|
9281cb74fb |
||
|
|
6d8ee66019 |
||
|
|
17135b45ae |
||
|
|
76c5841bc8 |
||
|
|
33b06bcd9b |
||
|
|
a1a55cb974 |
||
|
|
407955dce5 |
||
|
|
c4d923e9af |
||
|
|
9a152723e2 |
||
|
|
ef1171bb47 |
||
|
|
b1c92a067d |
||
|
|
db8de4804d |
||
|
|
6aeabd2036 |
||
|
|
dd703baba8 |
||
|
|
e48be221b1 |
||
|
|
a242f0e4b6 |
||
|
|
a8e8658ff5 |
||
|
|
4784ebc9f2 |
||
|
|
1d91b50a67 |
||
|
|
860d281a18 |
||
|
|
1cebe56707 |
||
|
|
1805b6d635 |
||
|
|
3dc1e86d8c |
||
|
|
0cf2df2dcb |
||
|
|
f9ee067e88 |
||
|
|
5a0d7ee4ed |
||
|
|
7eea2330c9 |
||
|
|
1455afa219 |
||
|
|
0e0af4d656 |
||
|
|
2118c8c745 |
||
|
|
218df89948 |
||
|
|
1d66d2f536 | ||
|
|
e00a81bfbf |
||
|
|
c7623e4c5a |
||
|
|
3e006654ea |
||
|
|
98ff395c36 |
||
|
|
e3d0fb27e9 |
||
|
|
1d98dd1223 | ||
|
|
f48a3ed76a | ||
|
|
242dd03961 |
||
|
|
efb2eef323 |
||
|
|
5fb5aa382d |
||
|
|
35dc29822f |
||
|
|
bdef451d89 |
||
|
|
badc420069 |
||
|
|
f2669214de |
||
|
|
f6a7f46e3a | ||
|
|
8aefea7260 |
||
|
|
c20cca4425 | ||
|
|
6ca9971b57 |
||
|
|
b9fee9b93b | ||
|
|
6f69f67cb8 |
||
|
|
18f230c611 |
||
|
|
9c518b8e6d |
||
|
|
c43156b86f |
||
|
|
7194ab60f1 |
||
|
|
65732f7aa0 |
||
|
|
4de7c1a4c0 |
||
|
|
0457413bf2 |
||
|
|
f7b4b8d2ff |
||
|
|
a8dc4ac5df | ||
|
|
17556ff1b6 |
||
|
|
31158584ae |
||
|
|
adb1f3f52b |
||
|
|
9b5a86485e |
||
|
|
4017706c18 |
||
|
|
c0017095bf |
||
|
|
84179e8eea |
||
|
|
f2e419e18d |
||
|
|
257f5d34d2 |
||
|
|
8bad631fb6 |
||
|
|
ed8118aa1e |
||
|
|
31264e1fe4 |
||
|
|
5706928063 |
||
|
|
67933b546f |
||
|
|
049826071c |
||
|
|
7d32a36d51 |
||
|
|
ab6b53668e |
||
|
|
a46058134f |
||
|
|
3730d51dce |
||
|
|
095c793381 |
||
|
|
6bce388dcd |
||
|
|
d7c0507407 | ||
|
|
b2f970fd66 | ||
|
|
64da75d605 | ||
|
|
6dbb0dfd9a |
||
|
|
47f89afd4e |
||
|
|
db607e463e | ||
|
|
bf6659bbbd | ||
|
|
642cad9ed8 | ||
|
|
4022faf778 |
||
|
|
f755a44108 | ||
|
|
e119f5c4c5 | ||
|
|
c2e58fa1b4 | ||
|
|
f2615aa155 | ||
|
|
8c5a494489 | ||
|
|
022a5b79bb | ||
|
|
b66588d7c9 | ||
|
|
50f9b505cd | ||
|
|
54ca4476cc | ||
|
|
0cbee4dc6c | ||
|
|
2e8b6070dc | ||
|
|
cdf137b484 | ||
|
|
bf22efdd32 | ||
|
|
d8e742a452 | ||
|
|
6fb0a47ad4 | ||
|
|
fa3cb87ba0 | ||
|
|
04d1494c90 |
||
|
|
c51b849fe6 | ||
|
|
bb69239663 |
||
|
|
9b7d96ca3d | ||
|
|
5085258bdf | ||
|
|
11a9d02f93 | ||
|
|
b672a7710c | ||
|
|
096190cfd2 | ||
|
|
8e5ed7d23d | ||
|
|
aadebddd82 | ||
|
|
8c2f718c66 | ||
|
|
e46f1fb027 | ||
|
|
fcd5a30712 | ||
|
|
07a39e6343 | ||
|
|
bea8db1595 | ||
|
|
5109833b8f | ||
|
|
f2a72a31f1 | ||
|
|
8fbaaeb31b | ||
|
|
0ae3751cdc | ||
|
|
8ad4d5564b |
||
|
|
89f9cda6f4 |
||
|
|
572ab62a02 | ||
|
|
8082913eba | ||
|
|
017cb7353f | ||
|
|
58737b58e7 | ||
|
|
afd1cece0b | ||
|
|
a665714978 | ||
|
|
e3fafa5c20 | ||
|
|
57c36e50aa | ||
|
|
1176fd0322 | ||
|
|
f904c78462 | ||
|
|
6367dee6c2 | ||
|
|
abf0305853 | ||
|
|
9fa895aa6d | ||
|
|
4a2d41964d |
||
|
|
ec091817c7 | ||
|
|
a44f70dcb6 | ||
|
|
f95814577e | ||
|
|
c476d79580 | ||
|
|
577a3932f5 | ||
|
|
3644e0e254 | ||
|
|
80aba99673 |
43 changed files with 4660 additions and 1159 deletions
10
.github/workflows/lint.yml
vendored
Normal file
10
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
name: lint
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
|
||||||
58
.github/workflows/release.yml
vendored
Normal file
58
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Create github release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes
|
||||||
|
|
||||||
|
pullrequest:
|
||||||
|
needs: [release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repository: lnbits/lnbits-extensions
|
||||||
|
path: './lnbits-extensions'
|
||||||
|
|
||||||
|
- name: setup git user
|
||||||
|
run: |
|
||||||
|
git config --global user.name "alan"
|
||||||
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
|
||||||
|
- name: Create pull request in extensions repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repo_name: '${{ github.event.repository.name }}'
|
||||||
|
tag: '${{ github.ref_name }}'
|
||||||
|
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
||||||
|
title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}'
|
||||||
|
body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}'
|
||||||
|
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
||||||
|
run: |
|
||||||
|
cd lnbits-extensions
|
||||||
|
git checkout -b $branch
|
||||||
|
|
||||||
|
# if there is another open PR
|
||||||
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
|
||||||
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -am "$title"
|
||||||
|
git push origin $branch
|
||||||
|
|
||||||
|
# check if pr exists before creating it
|
||||||
|
gh config set pager cat
|
||||||
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__
|
||||||
|
node_modules
|
||||||
|
.mypy_cache
|
||||||
|
.venv
|
||||||
12
.prettierrc
Normal file
12
.prettierrc
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"insertPragma": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"bracketSpacing": false
|
||||||
|
}
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 LNbits
|
||||||
|
|
||||||
|
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.
|
||||||
47
Makefile
Normal file
47
Makefile
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
all: format check
|
||||||
|
|
||||||
|
format: prettier black ruff
|
||||||
|
|
||||||
|
check: mypy pyright checkblack checkruff checkprettier
|
||||||
|
|
||||||
|
prettier:
|
||||||
|
uv run ./node_modules/.bin/prettier --write .
|
||||||
|
pyright:
|
||||||
|
uv run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
|
mypy:
|
||||||
|
uv run mypy .
|
||||||
|
|
||||||
|
black:
|
||||||
|
uv run black .
|
||||||
|
|
||||||
|
ruff:
|
||||||
|
uv run ruff check . --fix
|
||||||
|
|
||||||
|
checkruff:
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
checkprettier:
|
||||||
|
uv run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
|
checkblack:
|
||||||
|
uv run black --check .
|
||||||
|
|
||||||
|
checkeditorconfig:
|
||||||
|
editorconfig-checker
|
||||||
|
|
||||||
|
test:
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DEBUG=true \
|
||||||
|
uv 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
|
||||||
|
|
||||||
|
pre-commit:
|
||||||
|
uv run pre-commit run --all-files
|
||||||
|
|
||||||
|
|
||||||
|
checkbundle:
|
||||||
|
@echo "skipping checkbundle"
|
||||||
53
README.md
53
README.md
|
|
@ -1,6 +1,18 @@
|
||||||
# LNURLp
|
<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>
|
||||||
|
|
||||||
## Create a static QR code people can use to pay over Lightning Network
|
[](./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>
|
||||||
|
|
||||||
|
## Create a static QR code or LNaddress people can use to pay over Lightning Network
|
||||||
|
|
||||||
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet.
|
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet.
|
||||||
|
|
||||||
|
|
@ -10,7 +22,6 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
|
||||||
|
|
||||||
1. Create an LNURLp (New Pay link)\
|
1. Create an LNURLp (New Pay link)\
|
||||||

|

|
||||||
|
|
||||||
- select your wallets
|
- select your wallets
|
||||||
- make a small description
|
- make a small description
|
||||||
- enter amount
|
- enter amount
|
||||||
|
|
@ -25,3 +36,39 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
|
||||||

|

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

|

|
||||||
|
|
||||||
|
3. Optional - add Lightning Address
|
||||||
|
- attach a username to your lnurlp to create a lightning address
|
||||||
|
- the LN address format will be username@lnbits-domain-name
|
||||||
|
- Find out more about the lightning address spec at lightningaddress.com
|
||||||
|
|
||||||
|
## Update your LNURL-pay extension
|
||||||
|
|
||||||
|
Now that the extensions are taken out of core LNbits we can update each extension separately without the need to reload or restart LNbits as a whole.
|
||||||
|
This new version of the extension will give you the option to add a Lightning Address to each LNURLpay link.
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
- Open the details of the extension and click on version 0.2.1, click "Install". You´re done!
|
||||||
|
|
||||||
|
[](https://postimg.cc/xqFWtDfq)
|
||||||
|
|
||||||
|
- Open the LNURLp extension from the left panel
|
||||||
|
- If you already have had some LNURLp defined, you can now click on edit and add a LN Address to each. _Note that this will change your QR-Code!_
|
||||||
|
- If you didn't create any LNURLp before nothing changed except the window for defining new ones
|
||||||
|
|
||||||
|
[](https://postimg.cc/tnnhNVkq)
|
||||||
|
|
||||||
|
Now you can receive sats to your newly created LN address. You will find this info also in the transaction overview for each payment (click on the green arrow).
|
||||||
|
|
||||||
|
[](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)
|
||||||
|
|
|
||||||
89
__init__.py
89
__init__.py
|
|
@ -1,38 +1,89 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from lnbits.db import Database
|
from .crud import db
|
||||||
from lnbits.helpers import template_renderer
|
from .tasks import wait_for_paid_invoices
|
||||||
from lnbits.tasks import catch_everything_and_restart
|
from .views import lnurlp_generic_router
|
||||||
|
from .views_api import lnurlp_api_router
|
||||||
db = Database("ext_lnurlp")
|
from .views_lnurl import lnurlp_lnurl_router
|
||||||
|
|
||||||
lnurlp_static_files = [
|
lnurlp_static_files = [
|
||||||
{
|
{
|
||||||
"path": "/lnurlp/static",
|
"path": "/lnurlp/static",
|
||||||
"app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]),
|
|
||||||
"name": "lnurlp_static",
|
"name": "lnurlp_static",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
scheduled_tasks: List[asyncio.Task] = []
|
|
||||||
|
lnurlp_redirect_paths = [
|
||||||
|
{
|
||||||
|
"from_path": "/.well-known/lnurlp",
|
||||||
|
"redirect_to_path": "/api/v1/well-known",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
|
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
|
||||||
def lnurlp_renderer():
|
def lnurlp_stop():
|
||||||
return template_renderer(["lnbits/extensions/lnurlp/templates"])
|
for task in scheduled_tasks:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
from .lnurl import * # noqa: F401,F403
|
|
||||||
from .tasks import wait_for_paid_invoices
|
|
||||||
from .views import * # noqa: F401,F403
|
|
||||||
from .views_api import * # noqa: F401,F403
|
|
||||||
|
|
||||||
|
|
||||||
def lnurlp_start():
|
def lnurlp_start():
|
||||||
loop = asyncio.get_event_loop()
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
|
||||||
|
task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices)
|
||||||
scheduled_tasks.append(task)
|
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_stop",
|
||||||
|
]
|
||||||
|
|
|
||||||
64
config.json
64
config.json
|
|
@ -1,10 +1,62 @@
|
||||||
{
|
{
|
||||||
"name": "LNURLp",
|
"id": "paylink",
|
||||||
"short_description": "Make reusable LNURL pay links",
|
"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": "",
|
||||||
"tile": "/lnurlp/static/image/lnurl-pay.png",
|
"tile": "/lnurlp/static/image/lnurl-pay.png",
|
||||||
|
"min_lnbits_version": "1.4.0",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
"arcbtc",
|
{
|
||||||
"eillarra",
|
"name": "arcbtc",
|
||||||
"fiatjaf"
|
"uri": "https://github.com/arcbtc",
|
||||||
]
|
"role": "Contributor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "eillarra",
|
||||||
|
"uri": "https://github.com/eillarra",
|
||||||
|
"role": "Contributor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fiatjaf",
|
||||||
|
"uri": "https://github.com/fiatjaf",
|
||||||
|
"role": "Contributor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "callebtc",
|
||||||
|
"uri": "https://github.com/callebtc",
|
||||||
|
"role": "Contributor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dni",
|
||||||
|
"uri": "https://github.com/dni",
|
||||||
|
"role": "Contributor"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/1.jpg",
|
||||||
|
"link": "https://www.youtube.com/embed/WZpK4xfGcuY?si=_T3gCqKBU8yt6_bD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/lnbits/lnurlp/main/static/image/4.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
crud.py
163
crud.py
|
|
@ -1,95 +1,108 @@
|
||||||
from typing import List, Optional, Union
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from pynostr.key import PrivateKey
|
||||||
|
|
||||||
from . import db
|
from .models import CreatePayLinkData, LnurlpSettings, PayLink
|
||||||
from .models import CreatePayLinkData, PayLink
|
|
||||||
|
db = Database("ext_lnurlp")
|
||||||
|
|
||||||
|
|
||||||
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
||||||
|
settings = await db.fetchone(
|
||||||
|
"SELECT * FROM lnurlp.settings LIMIT 1", model=LnurlpSettings
|
||||||
|
)
|
||||||
|
if settings:
|
||||||
|
return settings
|
||||||
|
else:
|
||||||
|
settings = LnurlpSettings(nostr_private_key=PrivateKey().hex())
|
||||||
|
await db.insert("lnurlp.settings", settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings:
|
||||||
|
await db.update("lnurlp.settings", settings, "")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
"SELECT * FROM lnurlp.pay_links WHERE username = :username",
|
||||||
|
{"username": username},
|
||||||
|
PayLink,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_pay_link(data: CreatePayLinkData) -> PayLink:
|
||||||
link_id = urlsafe_short_hash()[:6]
|
link_id = urlsafe_short_hash()[:6]
|
||||||
|
|
||||||
result = await db.execute(
|
assert data.wallet, "Wallet is required"
|
||||||
"""
|
now = datetime.now(timezone.utc)
|
||||||
INSERT INTO lnurlp.pay_links (
|
|
||||||
id,
|
|
||||||
wallet,
|
|
||||||
description,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
served_meta,
|
|
||||||
served_pr,
|
|
||||||
webhook_url,
|
|
||||||
webhook_headers,
|
|
||||||
webhook_body,
|
|
||||||
success_text,
|
|
||||||
success_url,
|
|
||||||
comment_chars,
|
|
||||||
currency,
|
|
||||||
fiat_base_multiplier
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
link_id,
|
|
||||||
wallet_id,
|
|
||||||
data.description,
|
|
||||||
data.min,
|
|
||||||
data.max,
|
|
||||||
data.webhook_url,
|
|
||||||
data.webhook_headers,
|
|
||||||
data.webhook_body,
|
|
||||||
data.success_text,
|
|
||||||
data.success_url,
|
|
||||||
data.comment_chars,
|
|
||||||
data.currency,
|
|
||||||
data.fiat_base_multiplier,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert result
|
|
||||||
|
|
||||||
link = await get_pay_link(link_id)
|
link = PayLink(
|
||||||
assert link, "Newly created link couldn't be retrieved"
|
id=link_id,
|
||||||
|
wallet=data.wallet,
|
||||||
|
description=data.description,
|
||||||
|
min=data.min,
|
||||||
|
max=data.max,
|
||||||
|
served_meta=0,
|
||||||
|
served_pr=0,
|
||||||
|
username=data.username,
|
||||||
|
zaps=data.zaps,
|
||||||
|
webhook_url=data.webhook_url,
|
||||||
|
webhook_headers=data.webhook_headers,
|
||||||
|
webhook_body=data.webhook_body,
|
||||||
|
success_text=data.success_text,
|
||||||
|
success_url=data.success_url,
|
||||||
|
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
|
return link
|
||||||
|
|
||||||
|
|
||||||
async def get_pay_link(link_id: str) -> Optional[PayLink]:
|
async def get_address_data(username: str) -> PayLink | None:
|
||||||
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
return await db.fetchone(
|
||||||
return PayLink.from_row(row) if row else None
|
"SELECT * FROM lnurlp.pay_links WHERE username = :username",
|
||||||
|
{"username": username},
|
||||||
|
PayLink,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
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_links(wallet_ids: str | list[str]) -> list[PayLink]:
|
||||||
if isinstance(wallet_ids, str):
|
if isinstance(wallet_ids, str):
|
||||||
wallet_ids = [wallet_ids]
|
wallet_ids = [wallet_ids]
|
||||||
|
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
|
||||||
q = ",".join(["?"] * len(wallet_ids))
|
return await db.fetchall(
|
||||||
rows = await db.fetchall(
|
f"SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) ORDER BY Id",
|
||||||
f"""
|
model=PayLink,
|
||||||
SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q})
|
|
||||||
ORDER BY Id
|
|
||||||
""",
|
|
||||||
(*wallet_ids,),
|
|
||||||
)
|
)
|
||||||
return [PayLink.from_row(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
async def update_pay_link(link: PayLink) -> PayLink:
|
||||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
link.updated_at = datetime.now(timezone.utc)
|
||||||
await db.execute(
|
await db.update("lnurlp.pay_links", link)
|
||||||
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
return link
|
||||||
)
|
|
||||||
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
|
||||||
return PayLink.from_row(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
async def delete_pay_link(link_id: str) -> None:
|
||||||
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
await db.execute("DELETE FROM lnurlp.pay_links WHERE id = :id", {"id": link_id})
|
||||||
await db.execute(
|
|
||||||
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
|
|
||||||
)
|
|
||||||
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
|
||||||
return PayLink.from_row(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_pay_link(link_id: int) -> None:
|
|
||||||
await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))
|
|
||||||
|
|
|
||||||
10
description.md
Normal file
10
description.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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.
|
||||||
25
helpers.py
Normal file
25
helpers.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from fastapi import Request
|
||||||
|
from lnurl import encode as lnurl_encode
|
||||||
|
from pynostr.key import PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nostr_private_key(key: str) -> PrivateKey:
|
||||||
|
if key.startswith("nsec"):
|
||||||
|
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)
|
||||||
106
lnurl.py
106
lnurl.py
|
|
@ -1,106 +0,0 @@
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice
|
|
||||||
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
|
||||||
|
|
||||||
from . import lnurlp_ext
|
|
||||||
from .crud import increment_pay_link
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get(
|
|
||||||
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL)
|
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
name="lnurlp.api_lnurl_response.deprecated",
|
|
||||||
)
|
|
||||||
@lnurlp_ext.get(
|
|
||||||
"/{link_id}",
|
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
name="lnurlp.api_lnurl_response",
|
|
||||||
)
|
|
||||||
async def api_lnurl_response(request: Request, link_id):
|
|
||||||
link = await increment_pay_link(link_id, served_meta=1)
|
|
||||||
if not link:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
|
||||||
|
|
||||||
resp = LnurlPayResponse(
|
|
||||||
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
|
|
||||||
min_sendable=round(link.min * rate) * 1000,
|
|
||||||
max_sendable=round(link.max * rate) * 1000,
|
|
||||||
metadata=link.lnurlpay_metadata,
|
|
||||||
)
|
|
||||||
params = resp.dict()
|
|
||||||
|
|
||||||
if link.comment_chars > 0:
|
|
||||||
params["commentAllowed"] = link.comment_chars
|
|
||||||
|
|
||||||
return params
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get(
|
|
||||||
"/api/v1/lnurl/cb/{link_id}",
|
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
name="lnurlp.api_lnurl_callback",
|
|
||||||
)
|
|
||||||
async def api_lnurl_callback(request: Request, link_id):
|
|
||||||
link = await increment_pay_link(link_id, served_pr=1)
|
|
||||||
if not link:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
|
||||||
)
|
|
||||||
min, max = link.min, link.max
|
|
||||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
|
||||||
if link.currency:
|
|
||||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
|
||||||
min = rate * 995 * link.min
|
|
||||||
max = rate * 1010 * link.max
|
|
||||||
else:
|
|
||||||
min = link.min * 1000
|
|
||||||
max = link.max * 1000
|
|
||||||
|
|
||||||
amount_received = int(request.query_params.get("amount") or 0)
|
|
||||||
if amount_received < min:
|
|
||||||
return LnurlErrorResponse(
|
|
||||||
reason=f"Amount {amount_received} is smaller than minimum {min}."
|
|
||||||
).dict()
|
|
||||||
|
|
||||||
elif amount_received > max:
|
|
||||||
return LnurlErrorResponse(
|
|
||||||
reason=f"Amount {amount_received} is greater than maximum {max}."
|
|
||||||
).dict()
|
|
||||||
|
|
||||||
comment = request.query_params.get("comment")
|
|
||||||
if len(comment or "") > link.comment_chars:
|
|
||||||
return LnurlErrorResponse(
|
|
||||||
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
|
|
||||||
).dict()
|
|
||||||
|
|
||||||
payment_hash, payment_request = await create_invoice(
|
|
||||||
wallet_id=link.wallet,
|
|
||||||
amount=int(amount_received / 1000),
|
|
||||||
memo=link.description,
|
|
||||||
unhashed_description=link.lnurlpay_metadata.encode(),
|
|
||||||
extra={
|
|
||||||
"tag": "lnurlp",
|
|
||||||
"link": link.id,
|
|
||||||
"comment": comment,
|
|
||||||
"extra": request.query_params.get("amount"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
success_action = link.success_action(payment_hash)
|
|
||||||
if success_action:
|
|
||||||
resp = LnurlPayActionResponse(
|
|
||||||
pr=payment_request, success_action=success_action, routes=[]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
resp = LnurlPayActionResponse(pr=payment_request, routes=[])
|
|
||||||
|
|
||||||
return resp.dict()
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
from lnbits.db import Connection
|
||||||
|
|
||||||
|
|
||||||
async def m001_initial(db):
|
async def m001_initial(db):
|
||||||
"""
|
"""
|
||||||
Initial pay table.
|
Initial pay table.
|
||||||
"""
|
"""
|
||||||
await db.execute(
|
await db.execute(f"""
|
||||||
f"""
|
|
||||||
CREATE TABLE lnurlp.pay_links (
|
CREATE TABLE lnurlp.pay_links (
|
||||||
id {db.serial_primary_key},
|
id {db.serial_primary_key},
|
||||||
wallet TEXT NOT NULL,
|
wallet TEXT NOT NULL,
|
||||||
|
|
@ -12,8 +16,7 @@ async def m001_initial(db):
|
||||||
served_meta INTEGER NOT NULL,
|
served_meta INTEGER NOT NULL,
|
||||||
served_pr INTEGER NOT NULL
|
served_pr INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m002_webhooks_and_success_actions(db):
|
async def m002_webhooks_and_success_actions(db):
|
||||||
|
|
@ -23,16 +26,14 @@ 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 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_text TEXT;")
|
||||||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
|
||||||
await db.execute(
|
await db.execute(f"""
|
||||||
f"""
|
|
||||||
CREATE TABLE lnurlp.invoices (
|
CREATE TABLE lnurlp.invoices (
|
||||||
pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
|
pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
|
||||||
payment_hash TEXT NOT NULL,
|
payment_hash TEXT NOT NULL,
|
||||||
webhook_sent INT, -- null means not sent, otherwise store status
|
webhook_sent INT, -- null means not sent, otherwise store status
|
||||||
expiry INT
|
expiry INT
|
||||||
);
|
);
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def m003_min_max_comment_fiat(db):
|
async def m003_min_max_comment_fiat(db):
|
||||||
|
|
@ -58,7 +59,8 @@ async def m004_fiat_base_multiplier(db):
|
||||||
remember to multiply by 100 when we use it to convert to Dollars.
|
remember to multiply by 100 when we use it to convert to Dollars.
|
||||||
"""
|
"""
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
"ALTER TABLE lnurlp.pay_links "
|
||||||
|
"ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,8 +82,7 @@ async def m006_redux(db):
|
||||||
else:
|
else:
|
||||||
# but we have to do this for sqlite
|
# but we have to do this for sqlite
|
||||||
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
|
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
|
||||||
await db.execute(
|
await db.execute(f"""
|
||||||
f"""
|
|
||||||
CREATE TABLE lnurlp.pay_links (
|
CREATE TABLE lnurlp.pay_links (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
wallet TEXT NOT NULL,
|
wallet TEXT NOT NULL,
|
||||||
|
|
@ -99,8 +100,7 @@ async def m006_redux(db):
|
||||||
webhook_headers TEXT,
|
webhook_headers TEXT,
|
||||||
webhook_body TEXT
|
webhook_body TEXT
|
||||||
);
|
);
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
|
|
||||||
for row in [
|
for row in [
|
||||||
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
|
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
|
||||||
|
|
@ -146,3 +146,63 @@ async def m006_redux(db):
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.execute("DROP TABLE lnurlp.pay_links_old")
|
await db.execute("DROP TABLE lnurlp.pay_links_old")
|
||||||
|
|
||||||
|
|
||||||
|
async def m007_add_lnaddress_username(db):
|
||||||
|
"""
|
||||||
|
Add Lightning address to pay links
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;")
|
||||||
|
|
||||||
|
|
||||||
|
async def m008_add_zap_enabled_column(db):
|
||||||
|
"""
|
||||||
|
Add Nostr zaps to pay links
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN zaps BOOLEAN;")
|
||||||
|
|
||||||
|
|
||||||
|
async def m009_add_settings(db):
|
||||||
|
"""
|
||||||
|
Add extension settings table
|
||||||
|
"""
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE lnurlp.settings (
|
||||||
|
nostr_private_key TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
|
||||||
129
models.py
129
models.py
|
|
@ -1,28 +1,41 @@
|
||||||
import json
|
from datetime import datetime, timezone
|
||||||
from sqlite3 import Row
|
|
||||||
from typing import Dict, Optional
|
|
||||||
from urllib.parse import ParseResult, urlparse, urlunparse
|
|
||||||
|
|
||||||
from fastapi.param_functions import Query
|
from fastapi import Query
|
||||||
from lnurl.types import LnurlPayMetadata
|
from pydantic import BaseModel, Field
|
||||||
from pydantic import BaseModel
|
from pynostr.key import PrivateKey
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
from lnbits.lnurl import encode as lnurl_encode
|
from .helpers import parse_nostr_private_key
|
||||||
|
|
||||||
|
|
||||||
|
class LnurlpSettings(BaseModel):
|
||||||
|
nostr_private_key: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def private_key(self) -> PrivateKey:
|
||||||
|
return parse_nostr_private_key(self.nostr_private_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_key(self) -> str:
|
||||||
|
return self.private_key.public_key.hex()
|
||||||
|
|
||||||
|
|
||||||
class CreatePayLinkData(BaseModel):
|
class CreatePayLinkData(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
|
wallet: str | None = None
|
||||||
min: float = Query(1, ge=0.01)
|
min: float = Query(1, ge=0.01)
|
||||||
max: 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)
|
||||||
comment_chars: int = Query(0, ge=0, lt=800)
|
currency: str | None = Query(None)
|
||||||
webhook_url: str = Query(None)
|
webhook_url: str | None = Query(None)
|
||||||
webhook_headers: str = Query(None)
|
webhook_headers: str | None = Query(None)
|
||||||
webhook_body: str = Query(None)
|
webhook_body: str | None = Query(None)
|
||||||
success_text: str = Query(None)
|
success_text: str | None = Query(None)
|
||||||
success_url: str = Query(None)
|
success_url: str | None = Query(None)
|
||||||
fiat_base_multiplier: int = Query(100, ge=1)
|
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)
|
||||||
|
|
||||||
|
|
||||||
class PayLink(BaseModel):
|
class PayLink(BaseModel):
|
||||||
|
|
@ -30,46 +43,50 @@ class PayLink(BaseModel):
|
||||||
wallet: str
|
wallet: str
|
||||||
description: str
|
description: str
|
||||||
min: float
|
min: float
|
||||||
|
max: float
|
||||||
served_meta: int
|
served_meta: int
|
||||||
served_pr: int
|
served_pr: int
|
||||||
webhook_url: Optional[str]
|
|
||||||
webhook_headers: Optional[str]
|
|
||||||
webhook_body: Optional[str]
|
|
||||||
success_text: Optional[str]
|
|
||||||
success_url: Optional[str]
|
|
||||||
currency: Optional[str]
|
|
||||||
comment_chars: int
|
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
|
max: float
|
||||||
fiat_base_multiplier: int
|
domain: str | None = None
|
||||||
|
currency: str | None = None
|
||||||
@classmethod
|
lnurl: str | None = Field(
|
||||||
def from_row(cls, row: Row) -> "PayLink":
|
default=None,
|
||||||
data = dict(row)
|
no_database=True,
|
||||||
if data["currency"] and data["fiat_base_multiplier"]:
|
deprecated=True,
|
||||||
data["min"] /= data["fiat_base_multiplier"]
|
description=(
|
||||||
data["max"] /= data["fiat_base_multiplier"]
|
"Deprecated: Instead of using this bech32 encoded string, dynamically "
|
||||||
return cls(**data)
|
"generate your own static link (lud17/bech32) on the client side. "
|
||||||
|
"Example: lnurlp://${window.location.hostname}/lnurlp/${paylink_id}"
|
||||||
def lnurl(self, req: Request) -> str:
|
),
|
||||||
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
|
)
|
||||||
return lnurl_encode(url)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
|
||||||
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
|
|
||||||
|
|
||||||
def success_action(self, payment_hash: str) -> Optional[Dict]:
|
|
||||||
if self.success_url:
|
|
||||||
url: ParseResult = urlparse(self.success_url)
|
|
||||||
# qs = parse_qs(url.query)
|
|
||||||
# setattr(qs, "payment_hash", payment_hash)
|
|
||||||
# url = url._replace(query=urlencode(qs, doseq=True))
|
|
||||||
return {
|
|
||||||
"tag": "url",
|
|
||||||
"description": self.success_text or "~",
|
|
||||||
"url": urlunparse(url),
|
|
||||||
}
|
|
||||||
elif self.success_text:
|
|
||||||
return {"tag": "message", "message": self.success_text}
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
62
package-lock.json
generated
Normal file
62
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"name": "lnurlp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "lnurlp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"pyright": "^1.1.407"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"bin": {
|
||||||
|
"pyright": "index.js",
|
||||||
|
"pyright-langserver": "langserver.index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "lnurlp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"pyright": "^1.1.407"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
pyproject.toml
Normal file
97
pyproject.toml
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
[project]
|
||||||
|
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"]
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
|
[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.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.*",
|
||||||
|
]
|
||||||
|
ignore_missing_imports = "True"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
log_cli = false
|
||||||
|
testpaths = [
|
||||||
|
"tests"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Same as Black. + 10% rule of black
|
||||||
|
line-length = 88
|
||||||
|
# exclude = [
|
||||||
|
# "nostr",
|
||||||
|
# ]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# Enable:
|
||||||
|
# F - pyflakes
|
||||||
|
# E - pycodestyle errors
|
||||||
|
# W - pycodestyle warnings
|
||||||
|
# I - isort
|
||||||
|
# A - flake8-builtins
|
||||||
|
# C - mccabe
|
||||||
|
# N - naming
|
||||||
|
# UP - pyupgrade
|
||||||
|
# RUF - ruff
|
||||||
|
# B - bugbear
|
||||||
|
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
|
||||||
|
ignore = ["C901"]
|
||||||
|
|
||||||
|
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
||||||
|
fixable = ["ALL"]
|
||||||
|
unfixable = []
|
||||||
|
|
||||||
|
# Allow unused variables when underscore-prefixed.
|
||||||
|
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||||
|
|
||||||
|
# needed for pydantic
|
||||||
|
[tool.ruff.lint.pep8-naming]
|
||||||
|
classmethod-decorators = [
|
||||||
|
"root_validator",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ignore unused imports in __init__.py files.
|
||||||
|
# [tool.ruff.lint.extend-per-file-ignores]
|
||||||
|
# "__init__.py" = ["F401", "F403"]
|
||||||
|
|
||||||
|
# [tool.ruff.lint.mccabe]
|
||||||
|
# max-complexity = 10
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
|
||||||
|
extend-immutable-calls = [
|
||||||
|
"fastapi.Depends",
|
||||||
|
"fastapi.Query",
|
||||||
|
]
|
||||||
27
static/display.js
Normal file
27
static/display.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
67
static/display.vue
Normal file
67
static/display.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<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>
|
||||||
BIN
static/image/1.jpg
Normal file
BIN
static/image/1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
static/image/1.png
Normal file
BIN
static/image/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
static/image/2.png
Normal file
BIN
static/image/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
static/image/3.png
Normal file
BIN
static/image/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
static/image/4.png
Normal file
BIN
static/image/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -1,36 +1,69 @@
|
||||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
window.PageLnurlp = {
|
||||||
|
template: '#page-lnurlp',
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
computed: {
|
||||||
|
baseUrl() {
|
||||||
var locationPath = [
|
return window.location.origin + '/lnurlp/api/v1/links'
|
||||||
window.location.protocol,
|
},
|
||||||
'//',
|
endpoint() {
|
||||||
window.location.host,
|
return `/lnurlp/api/v1/settings?usr=${this.g.user.id}`
|
||||||
window.location.pathname
|
}
|
||||||
].join('')
|
},
|
||||||
|
|
||||||
var mapPayLink = obj => {
|
|
||||||
obj._data = _.clone(obj)
|
|
||||||
obj.date = Quasar.utils.date.formatDate(
|
|
||||||
new Date(obj.time * 1000),
|
|
||||||
'YYYY-MM-DD HH:mm'
|
|
||||||
)
|
|
||||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
|
||||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
|
||||||
obj.pay_url = [locationPath, 'link/', obj.id].join('')
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
currencies: [],
|
activeUrl: '',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
type: 'str',
|
||||||
|
description: 'Nostr private key used to zap',
|
||||||
|
name: 'nostr_private_key'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
domain: window.location.host,
|
||||||
fiatRates: {},
|
fiatRates: {},
|
||||||
checker: null,
|
|
||||||
payLinks: [],
|
payLinks: [],
|
||||||
payLinksTable: {
|
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: {
|
pagination: {
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +72,10 @@ new Vue({
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
fixedAmount: true,
|
fixedAmount: true,
|
||||||
data: {}
|
data: {
|
||||||
|
disposable: true,
|
||||||
|
zaps: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
qrCodeDialog: {
|
qrCodeDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
|
|
@ -48,6 +84,28 @@ new Vue({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
getPayLinks() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
|
|
@ -56,12 +114,9 @@ new Vue({
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.payLinks = response.data.map(mapPayLink)
|
this.payLinks = response.data.map(this.mapPayLink)
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
clearInterval(this.checker)
|
|
||||||
LNbits.utils.notifyApiError(err)
|
|
||||||
})
|
})
|
||||||
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
closeFormDialog() {
|
closeFormDialog() {
|
||||||
this.resetFormData()
|
this.resetFormData()
|
||||||
|
|
@ -89,16 +144,20 @@ new Vue({
|
||||||
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||||
: 'do nothing',
|
: 'do nothing',
|
||||||
lnurl: link.lnurl,
|
lnurl: link.lnurl,
|
||||||
|
domain: link.domain,
|
||||||
pay_url: link.pay_url,
|
pay_url: link.pay_url,
|
||||||
print_url: link.print_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
|
this.qrCodeDialog.show = true
|
||||||
},
|
},
|
||||||
openUpdateDialog(linkId) {
|
openUpdateDialog(linkId) {
|
||||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||||
if (link.currency) this.updateFiatRate(link.currency)
|
if (link.currency) this.updateFiatRate(link.currency)
|
||||||
|
|
||||||
this.formDialog.data = _.clone(link._data)
|
this.formDialog.data = {...link}
|
||||||
this.formDialog.show = true
|
this.formDialog.show = true
|
||||||
this.formDialog.fixedAmount =
|
this.formDialog.fixedAmount =
|
||||||
this.formDialog.data.min === this.formDialog.data.max
|
this.formDialog.data.min === this.formDialog.data.max
|
||||||
|
|
@ -107,12 +166,10 @@ new Vue({
|
||||||
const wallet = _.findWhere(this.g.user.wallets, {
|
const wallet = _.findWhere(this.g.user.wallets, {
|
||||||
id: this.formDialog.data.wallet
|
id: this.formDialog.data.wallet
|
||||||
})
|
})
|
||||||
var data = _.omit(this.formDialog.data, 'wallet')
|
const data = _.clone(this.formDialog.data)
|
||||||
|
|
||||||
if (this.formDialog.fixedAmount) data.max = data.min
|
if (this.formDialog.fixedAmount) data.max = data.min
|
||||||
if (data.currency === 'satoshis') data.currency = null
|
if (data.currency === 'satoshis') data.currency = null
|
||||||
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
|
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
|
||||||
|
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
this.updatePayLink(wallet, data)
|
this.updatePayLink(wallet, data)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -127,35 +184,16 @@ new Vue({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updatePayLink(wallet, data) {
|
updatePayLink(wallet, data) {
|
||||||
let values = _.omit(
|
|
||||||
_.pick(
|
|
||||||
data,
|
|
||||||
'description',
|
|
||||||
'min',
|
|
||||||
'max',
|
|
||||||
'webhook_url',
|
|
||||||
'success_text',
|
|
||||||
'success_url',
|
|
||||||
'comment_chars',
|
|
||||||
'currency'
|
|
||||||
),
|
|
||||||
(value, key) =>
|
|
||||||
(key === 'webhook_url' ||
|
|
||||||
key === 'success_text' ||
|
|
||||||
key === 'success_url') &&
|
|
||||||
(value === null || value === '')
|
|
||||||
)
|
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
'/lnurlp/api/v1/links/' + data.id,
|
'/lnurlp/api/v1/links/' + data.id,
|
||||||
wallet.adminkey,
|
wallet.adminkey,
|
||||||
values
|
data
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||||
this.payLinks.push(mapPayLink(response.data))
|
this.payLinks.push(this.mapPayLink(response.data))
|
||||||
this.formDialog.show = false
|
this.formDialog.show = false
|
||||||
this.resetFormData()
|
this.resetFormData()
|
||||||
})
|
})
|
||||||
|
|
@ -187,7 +225,7 @@ new Vue({
|
||||||
'/lnurlp/api/v1/links/' + linkId,
|
'/lnurlp/api/v1/links/' + linkId,
|
||||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(() => {
|
||||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|
@ -197,68 +235,18 @@ new Vue({
|
||||||
},
|
},
|
||||||
updateFiatRate(currency) {
|
updateFiatRate(currency) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
|
.request('GET', '/api/v1/rate/' + currency, null)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
let rates = _.clone(this.fiatRates)
|
this.fiatRates[currency] = response.data.rate
|
||||||
rates[currency] = response.data.rate
|
|
||||||
this.fiatRates = rates
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(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() {
|
created() {
|
||||||
if (this.g.user.wallets.length) {
|
if (this.g.user.wallets?.length) {
|
||||||
var getPayLinks = this.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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
686
static/index.vue
Normal file
686
static/index.vue
Normal file
|
|
@ -0,0 +1,686 @@
|
||||||
|
<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>
|
||||||
14
static/routes.json
Normal file
14
static/routes.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
154
tasks.py
154
tasks.py
|
|
@ -2,19 +2,20 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
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.core.models import Payment
|
||||||
from lnbits.helpers import get_current_extension_name
|
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
from loguru import logger
|
||||||
|
from pynostr.event import Event
|
||||||
|
|
||||||
from .crud import get_pay_link
|
from .crud import get_or_create_lnurlp_settings, get_pay_link
|
||||||
|
from .models import PayLink
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
invoice_queue = asyncio.Queue()
|
invoice_queue = asyncio.Queue()
|
||||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
register_invoice_listener(invoice_queue, "ext_lnurlp")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
payment = await invoice_queue.get()
|
payment = await invoice_queue.get()
|
||||||
|
|
@ -22,15 +23,36 @@ async def wait_for_paid_invoices():
|
||||||
|
|
||||||
|
|
||||||
async def on_invoice_paid(payment: Payment):
|
async def on_invoice_paid(payment: Payment):
|
||||||
if payment.extra.get("tag") != "lnurlp":
|
if not payment.extra or payment.extra.get("tag") != "lnurlp":
|
||||||
return
|
return
|
||||||
|
|
||||||
if payment.extra.get("wh_status"):
|
if payment.extra.get("wh_status"):
|
||||||
# this webhook has already been sent
|
# this webhook has already been sent
|
||||||
return
|
return
|
||||||
|
|
||||||
pay_link = await get_pay_link(payment.extra.get("link", -1))
|
pay_link_id = payment.extra.get("link")
|
||||||
if pay_link and pay_link.webhook_url:
|
if not pay_link_id:
|
||||||
|
logger.error("Invoice paid. But no pay link id found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
pay_link = await get_pay_link(pay_link_id)
|
||||||
|
if not pay_link:
|
||||||
|
logger.error(f"Invoice paid. But Pay link `{pay_link_id}` not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
zap_receipt = None
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
|
||||||
|
if not pay_link.webhook_url:
|
||||||
|
return
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
r: httpx.Response = await client.post(
|
r: httpx.Response = await client.post(
|
||||||
|
|
@ -39,41 +61,117 @@ async def on_invoice_paid(payment: Payment):
|
||||||
"payment_hash": payment.payment_hash,
|
"payment_hash": payment.payment_hash,
|
||||||
"payment_request": payment.bolt11,
|
"payment_request": payment.bolt11,
|
||||||
"amount": payment.amount,
|
"amount": payment.amount,
|
||||||
"comment": payment.extra.get("comment"),
|
"comment": payment.extra.get("comment") if payment.extra else None,
|
||||||
|
"webhook_data": (
|
||||||
|
payment.extra.get("webhook_data") if payment.extra else None
|
||||||
|
),
|
||||||
"lnurlp": pay_link.id,
|
"lnurlp": pay_link.id,
|
||||||
"body": json.loads(pay_link.webhook_body)
|
"body": (
|
||||||
|
json.loads(pay_link.webhook_body)
|
||||||
if pay_link.webhook_body
|
if pay_link.webhook_body
|
||||||
else "",
|
else ""
|
||||||
|
),
|
||||||
|
"zap_receipt": zap_receipt or "",
|
||||||
},
|
},
|
||||||
headers=json.loads(pay_link.webhook_headers)
|
headers=(
|
||||||
|
json.loads(pay_link.webhook_headers)
|
||||||
if pay_link.webhook_headers
|
if pay_link.webhook_headers
|
||||||
else None,
|
else None
|
||||||
timeout=40,
|
),
|
||||||
|
timeout=6,
|
||||||
)
|
)
|
||||||
await mark_webhook_sent(
|
await mark_webhook_sent(
|
||||||
payment.payment_hash,
|
payment.checking_id,
|
||||||
r.status_code,
|
r.status_code,
|
||||||
r.is_success,
|
r.is_success,
|
||||||
r.reason_phrase,
|
r.reason_phrase,
|
||||||
r.text,
|
r.text,
|
||||||
)
|
)
|
||||||
except Exception as ex:
|
except Exception as exc:
|
||||||
logger.error(ex)
|
logger.error(exc)
|
||||||
await mark_webhook_sent(
|
await mark_webhook_sent(
|
||||||
payment.payment_hash, -1, False, "Unexpected Error", str(ex)
|
payment.checking_id, -1, False, "Unexpected Error", str(exc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def mark_webhook_sent(
|
async def mark_webhook_sent(
|
||||||
payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
|
checking_id: str, status: int, is_success: bool, reason_phrase="", text=""
|
||||||
) -> None:
|
) -> 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,
|
# NIP-57 - load the zap request
|
||||||
{
|
async def send_zap(payment: Payment):
|
||||||
"wh_status": status, # keep for backwards compability
|
nostr = payment.extra.get("nostr") if payment.extra else None
|
||||||
"wh_success": is_success,
|
if not nostr:
|
||||||
"wh_message": reason_phrase,
|
return
|
||||||
"wh_response": text,
|
|
||||||
},
|
event_json = json.loads(nostr)
|
||||||
|
|
||||||
|
def get_tag(event_json, tag):
|
||||||
|
res = [event_tag[1:] for event_tag in event_json["tags"] if event_tag[0] == tag]
|
||||||
|
return res[0] if res else None
|
||||||
|
|
||||||
|
tags = []
|
||||||
|
for t in ["p", "e", "a"]:
|
||||||
|
tag = get_tag(event_json, t)
|
||||||
|
if tag:
|
||||||
|
tags.append([t, tag[0]])
|
||||||
|
tags.append(["bolt11", payment.bolt11])
|
||||||
|
tags.append(["description", nostr])
|
||||||
|
|
||||||
|
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(
|
||||||
|
kind=9735,
|
||||||
|
tags=tags,
|
||||||
|
content="",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
settings = await get_or_create_lnurlp_settings()
|
||||||
|
zap_receipt.sign(settings.private_key.hex())
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Get relays from the zap request, with a reasonable limit
|
||||||
|
relays = get_tag(event_json, "relays")
|
||||||
|
if not relays:
|
||||||
|
return zap_receipt
|
||||||
|
|
||||||
|
if len(relays) > 50:
|
||||||
|
relays = relays[:50]
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
<q-expansion-item
|
|
||||||
group="extras"
|
|
||||||
icon="swap_vertical_circle"
|
|
||||||
label="API info"
|
|
||||||
:content-inset-level="0.5"
|
|
||||||
>
|
|
||||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
|
||||||
<q-expansion-item group="api" dense expand-separator label="List pay links">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code><span class="text-blue">GET</span> /lnurlp/api/v1/links</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
|
||||||
Returns 200 OK (application/json)
|
|
||||||
</h5>
|
|
||||||
<code>[<pay_link_object>, ...]</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key:
|
|
||||||
{{ user.wallets[0].inkey }}"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code
|
|
||||||
><span class="text-blue">GET</span>
|
|
||||||
/lnurlp/api/v1/links/<pay_id></code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
|
||||||
Returns 201 CREATED (application/json)
|
|
||||||
</h5>
|
|
||||||
<code>{"lnurl": <string>}</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id>
|
|
||||||
-H "X-Api-Key: {{ user.wallets[0].inkey }}"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
group="api"
|
|
||||||
dense
|
|
||||||
expand-separator
|
|
||||||
label="Create a pay link"
|
|
||||||
>
|
|
||||||
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlp"></q-btn>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code><span class="text-green">POST</span> /lnurlp/api/v1/links</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
|
||||||
<code
|
|
||||||
>{"description": <string> "amount": <integer> "max":
|
|
||||||
<integer> "min": <integer> "comment_chars":
|
|
||||||
<integer>}</code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
|
||||||
Returns 201 CREATED (application/json)
|
|
||||||
</h5>
|
|
||||||
<code>{"lnurl": <string>}</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d
|
|
||||||
'{"description": <string>, "amount": <integer>, "max":
|
|
||||||
<integer>, "min": <integer>, "comment_chars":
|
|
||||||
<integer>}' -H "Content-type: application/json" -H "X-Api-Key:
|
|
||||||
{{ user.wallets[0].adminkey }}"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
group="api"
|
|
||||||
dense
|
|
||||||
expand-separator
|
|
||||||
label="Update a pay link"
|
|
||||||
>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code
|
|
||||||
><span class="text-green">PUT</span>
|
|
||||||
/lnurlp/api/v1/links/<pay_id></code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
|
||||||
<code>{"description": <string>, "amount": <integer>}</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
|
||||||
Returns 200 OK (application/json)
|
|
||||||
</h5>
|
|
||||||
<code>{"lnurl": <string>}</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id>
|
|
||||||
-d '{"description": <string>, "amount": <integer>}' -H
|
|
||||||
"Content-type: application/json" -H "X-Api-Key: {{
|
|
||||||
user.wallets[0].adminkey }}"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
group="api"
|
|
||||||
dense
|
|
||||||
expand-separator
|
|
||||||
label="Delete a pay link"
|
|
||||||
class="q-pb-md"
|
|
||||||
>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code
|
|
||||||
><span class="text-pink">DELETE</span>
|
|
||||||
/lnurlp/api/v1/links/<pay_id></code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
|
||||||
<code></code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X DELETE {{ request.base_url
|
|
||||||
}}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{
|
|
||||||
user.wallets[0].adminkey }}"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
{% extends "public.html" %} {% block page %}
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<div class="text-center">
|
|
||||||
<a class="text-secondary" href="lightning:{{ lnurl }}">
|
|
||||||
<q-responsive :ratio="1" class="q-mx-md">
|
|
||||||
<qrcode
|
|
||||||
value="lightning:{{ lnurl }}"
|
|
||||||
:options="{width: 800}"
|
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
|
||||||
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
|
||||||
>Copy LNURL</q-btn
|
|
||||||
>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
icon="nfc"
|
|
||||||
@click="writeNfcTag(' {{ lnurl }} ')"
|
|
||||||
:disable="nfcTagWriting"
|
|
||||||
></q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
|
|
||||||
<p class="q-my-none">Use an LNURL compatible bitcoin wallet to pay.</p>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-list> {% include "lnurlp/_lnurl.html" %} </q-list>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
|
||||||
%} {% block page %}
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
|
||||||
>New pay link</q-btn
|
|
||||||
>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col">
|
|
||||||
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-table
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
:data="payLinks"
|
|
||||||
row-key="id"
|
|
||||||
:pagination.sync="payLinksTable.pagination"
|
|
||||||
>
|
|
||||||
{% raw %}
|
|
||||||
<template v-slot:header="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th auto-width>Description</q-th>
|
|
||||||
<q-th auto-width>Amount</q-th>
|
|
||||||
<q-th auto-width>Currency</q-th>
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
icon="launch"
|
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
|
||||||
type="a"
|
|
||||||
:href="props.row.pay_url"
|
|
||||||
target="_blank"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
icon="visibility"
|
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
|
||||||
@click="openQrCodeDialog(props.row.id)"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>{{ props.row.description }}</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<span v-if="props.row.min == props.row.max">
|
|
||||||
{{ props.row.min }}
|
|
||||||
</span>
|
|
||||||
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
|
|
||||||
</q-td>
|
|
||||||
<q-td>{{ props.row.currency || 'sat' }}</q-td>
|
|
||||||
<q-td>
|
|
||||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
|
||||||
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon
|
|
||||||
v-if="props.row.success_text || props.row.success_url"
|
|
||||||
size="14px"
|
|
||||||
name="call_to_action"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
On success, show message '{{ props.row.success_text }}'
|
|
||||||
<span v-if="props.row.success_url"
|
|
||||||
>and URL '{{ props.row.success_url }}'</span
|
|
||||||
>
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
<q-icon
|
|
||||||
v-if="props.row.comment_chars > 0"
|
|
||||||
size="14px"
|
|
||||||
name="insert_comment"
|
|
||||||
>
|
|
||||||
<q-tooltip>
|
|
||||||
{{ props.row.comment_chars }}-char comment allowed
|
|
||||||
</q-tooltip>
|
|
||||||
</q-icon>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
@click="openUpdateDialog(props.row.id)"
|
|
||||||
icon="edit"
|
|
||||||
color="light-blue"
|
|
||||||
>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
@click="deletePayLink(props.row.id)"
|
|
||||||
icon="cancel"
|
|
||||||
color="pink"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
{% endraw %}
|
|
||||||
</q-table>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<h6 class="text-subtitle1 q-my-none">
|
|
||||||
{{SITE_TITLE}} LNURL-pay extension
|
|
||||||
</h6>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-list>
|
|
||||||
{% include "lnurlp/_api_docs.html" %}
|
|
||||||
<q-separator></q-separator>
|
|
||||||
{% include "lnurlp/_lnurl.html" %}
|
|
||||||
</q-list>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
||||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="formDialog.data.wallet"
|
|
||||||
:options="g.user.walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.description"
|
|
||||||
type="text"
|
|
||||||
label="Item description *"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.min"
|
|
||||||
type="number"
|
|
||||||
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
|
||||||
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
v-if="!formDialog.fixedAmount"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.max"
|
|
||||||
type="number"
|
|
||||||
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
|
|
||||||
label="Max *"
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<div class="col">
|
|
||||||
<q-checkbox
|
|
||||||
dense
|
|
||||||
v-model="formDialog.fixedAmount"
|
|
||||||
label="Fixed amount"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<q-select
|
|
||||||
dense
|
|
||||||
:options="currencies"
|
|
||||||
v-model="formDialog.data.currency"
|
|
||||||
:display-value="formDialog.data.currency || 'satoshis'"
|
|
||||||
label="Currency"
|
|
||||||
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
|
|
||||||
@input="updateFiatRate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.comment_chars"
|
|
||||||
type="number"
|
|
||||||
label="Comment maximum characters"
|
|
||||||
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formDialog.data.webhook_url"
|
|
||||||
type="text"
|
|
||||||
label="Webhook URL (optional)"
|
|
||||||
hint="A URL to be called whenever this link receives a payment."
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-if="formDialog.data.webhook_url"
|
|
||||||
v-model="formDialog.data.webhook_headers"
|
|
||||||
type="text"
|
|
||||||
label="Webhook headers (optional)"
|
|
||||||
hint="Custom data as JSON string, send headers along with the webhook."
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-if="formDialog.data.webhook_url"
|
|
||||||
v-model="formDialog.data.webhook_body"
|
|
||||||
type="text"
|
|
||||||
label="Webhook custom data (optional)"
|
|
||||||
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formDialog.data.success_text"
|
|
||||||
type="text"
|
|
||||||
label="Success message (optional)"
|
|
||||||
hint="Will be shown to the user in his wallet after a successful payment."
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formDialog.data.success_url"
|
|
||||||
type="text"
|
|
||||||
label="Success URL (optional)"
|
|
||||||
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
|
|
||||||
>
|
|
||||||
</q-input>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
v-if="formDialog.data.id"
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
>Update pay link</q-btn
|
|
||||||
>
|
|
||||||
<q-btn
|
|
||||||
v-else
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="
|
|
||||||
formDialog.data.wallet == null ||
|
|
||||||
formDialog.data.description == null ||
|
|
||||||
(
|
|
||||||
formDialog.data.min == null ||
|
|
||||||
formDialog.data.min <= 0
|
|
||||||
)
|
|
||||||
"
|
|
||||||
type="submit"
|
|
||||||
>Create pay link</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
||||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
|
||||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
|
||||||
{% raw %}
|
|
||||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
|
||||||
<qrcode
|
|
||||||
:value="'lightning:' + qrCodeDialog.data.lnurl"
|
|
||||||
:options="{width: 800}"
|
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode>
|
|
||||||
</q-responsive>
|
|
||||||
<p style="word-break: break-all">
|
|
||||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
|
||||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
|
|
||||||
<span v-if="qrCodeDialog.data.currency"
|
|
||||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
|
||||||
fiatRates[qrCodeDialog.data.currency] ?
|
|
||||||
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
|
||||||
/></span>
|
|
||||||
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
|
|
||||||
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
|
|
||||||
}}<br />
|
|
||||||
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
|
|
||||||
</p>
|
|
||||||
{% endraw %}
|
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
|
|
||||||
class="q-ml-sm"
|
|
||||||
>Copy LNURL</q-btn
|
|
||||||
>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
icon="link"
|
|
||||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
|
||||||
><q-tooltip>Copy sharable link</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
icon="nfc"
|
|
||||||
@click="writeNfcTag(qrCodeDialog.data.lnurl)"
|
|
||||||
:disable="nfcTagWriting"
|
|
||||||
><q-tooltip>Write to NFC</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="grey"
|
|
||||||
icon="print"
|
|
||||||
type="a"
|
|
||||||
:href="qrCodeDialog.data.print_url"
|
|
||||||
target="_blank"
|
|
||||||
><q-tooltip>Print</q-tooltip></q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
|
||||||
<script src="/lnurlp/static/js/index.js"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
{% extends "print.html" %} {% block page %}
|
|
||||||
<div class="row justify-center">
|
|
||||||
<div class="qr">
|
|
||||||
<qrcode value="lightning:{{ lnurl }}" :options="{width}"></qrcode>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block styles %}
|
|
||||||
<style>
|
|
||||||
.qr {
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
|
||||||
|
|
||||||
new Vue({
|
|
||||||
el: '#vue',
|
|
||||||
created: function () {
|
|
||||||
window.print()
|
|
||||||
},
|
|
||||||
data: function () {
|
|
||||||
return {width: window.innerWidth * 0.5}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
11
tests/test_init.py
Normal file
11
tests/test_init.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .. import lnurlp_ext
|
||||||
|
|
||||||
|
|
||||||
|
# just import router and add it to a test router
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_router():
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(lnurlp_ext)
|
||||||
29
toc.md
Normal file
29
toc.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Terms and Conditions for LNbits Extension
|
||||||
|
|
||||||
|
## 1. Acceptance of Terms
|
||||||
|
|
||||||
|
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
|
||||||
|
|
||||||
|
## 2. License
|
||||||
|
|
||||||
|
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
|
||||||
|
|
||||||
|
## 3. No Warranty
|
||||||
|
|
||||||
|
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
|
||||||
|
|
||||||
|
## 4. Limitation of Liability
|
||||||
|
|
||||||
|
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
|
||||||
|
|
||||||
|
## 5. Modification of Terms
|
||||||
|
|
||||||
|
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
|
||||||
|
|
||||||
|
## 6. General Provisions
|
||||||
|
|
||||||
|
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
|
||||||
|
|
||||||
|
## 7. Contact Information
|
||||||
|
|
||||||
|
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
||||||
151
transport_rpcs.py
Normal file
151
transport_rpcs.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""
|
||||||
|
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,43 +1,17 @@
|
||||||
from http import HTTPStatus
|
from fastapi import APIRouter, Depends
|
||||||
|
from lnbits.core.views.generic import index, index_public
|
||||||
from fastapi import Depends, Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
from lnbits.core.models import User
|
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
from . import lnurlp_ext, lnurlp_renderer
|
lnurlp_generic_router = APIRouter()
|
||||||
from .crud import get_pay_link
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
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
|
||||||
|
)
|
||||||
|
|
||||||
@lnurlp_ext.get("/", response_class=HTMLResponse)
|
lnurlp_generic_router.add_api_route(
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
"/print/{link_id}", methods=["GET"], endpoint=index_public
|
||||||
return lnurlp_renderer().TemplateResponse(
|
)
|
||||||
"lnurlp/index.html", {"request": request, "user": user.dict()}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/link/{link_id}", response_class=HTMLResponse)
|
|
||||||
async def display(request: Request, link_id):
|
|
||||||
link = await get_pay_link(link_id)
|
|
||||||
if not link:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
|
||||||
)
|
|
||||||
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
|
||||||
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
|
||||||
async def print_qr(request: Request, link_id):
|
|
||||||
link = await get_pay_link(link_id)
|
|
||||||
if not link:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
|
||||||
)
|
|
||||||
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
|
||||||
return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)
|
|
||||||
|
|
|
||||||
232
views_api.py
232
views_api.py
|
|
@ -1,60 +1,73 @@
|
||||||
import json
|
import json
|
||||||
from asyncio.log import logger
|
import re
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import Depends, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
from lnbits.core.crud import get_user, get_wallet
|
||||||
from starlette.exceptions import HTTPException
|
from lnbits.core.models import SimpleStatus, WalletTypeInfo
|
||||||
|
from lnbits.decorators import (
|
||||||
|
check_admin,
|
||||||
|
require_admin_key,
|
||||||
|
require_invoice_key,
|
||||||
|
)
|
||||||
|
from lnurl import InvalidUrl
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
|
||||||
from lnbits.decorators import WalletTypeInfo, check_admin, get_key_type
|
|
||||||
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
|
||||||
|
|
||||||
from . import lnurlp_ext, scheduled_tasks
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_pay_link,
|
create_pay_link,
|
||||||
|
delete_lnurlp_settings,
|
||||||
delete_pay_link,
|
delete_pay_link,
|
||||||
|
get_or_create_lnurlp_settings,
|
||||||
get_pay_link,
|
get_pay_link,
|
||||||
|
get_pay_link_by_username,
|
||||||
get_pay_links,
|
get_pay_links,
|
||||||
|
update_lnurlp_settings,
|
||||||
update_pay_link,
|
update_pay_link,
|
||||||
)
|
)
|
||||||
from .models import CreatePayLinkData
|
from .helpers import lnurl_encode_link, parse_nostr_private_key
|
||||||
|
from .models import CreatePayLinkData, LnurlpSettings, PayLink, PublicPayLink
|
||||||
|
|
||||||
|
lnurlp_api_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/currencies")
|
def check_lnurl_encode(req: Request, link: PayLink) -> str:
|
||||||
async def api_list_currencies_available():
|
try:
|
||||||
return list(currencies.keys())
|
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_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||||
async def api_links(
|
async def api_links(
|
||||||
req: Request,
|
req: Request,
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
):
|
) -> list[PayLink]:
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [key_info.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(key_info.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
try:
|
links = await get_pay_links(wallet_ids)
|
||||||
return [
|
for link in links:
|
||||||
{**link.dict(), "lnurl": link.lnurl(req)}
|
link.lnurl = check_lnurl_encode(req, link)
|
||||||
for link in await get_pay_links(wallet_ids)
|
return links
|
||||||
]
|
|
||||||
|
|
||||||
except LnurlInvalidUrl:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
|
||||||
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.get("/api/v1/links/{link_id}")
|
||||||
async def api_link_retrieve(
|
async def api_link_retrieve(
|
||||||
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
req: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
):
|
) -> PayLink:
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
|
|
@ -62,31 +75,55 @@ async def api_link_retrieve(
|
||||||
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.wallet != wallet.wallet.id:
|
link_wallet = await get_wallet(link.wallet)
|
||||||
|
|
||||||
|
# admins are allowed to read 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 and link_wallet.user != key_info.wallet.user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
return {**link.dict(), **{"lnurl": link.lnurl(r)}}
|
link.lnurl = check_lnurl_encode(req, link)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
@lnurlp_api_router.get("/api/v1/links/public/{link_id}", response_model=PublicPayLink)
|
||||||
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def check_username_exists(username: str):
|
||||||
|
prev_link = await get_pay_link_by_username(username)
|
||||||
|
if prev_link:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Username already taken.",
|
||||||
|
status_code=HTTPStatus.CONFLICT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
async def api_link_create_or_update(
|
||||||
|
req: Request,
|
||||||
data: CreatePayLinkData,
|
data: CreatePayLinkData,
|
||||||
request: Request,
|
link_id: str | None = None,
|
||||||
link_id=None,
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
) -> PayLink:
|
||||||
):
|
|
||||||
|
|
||||||
if data.min > data.max:
|
if data.min > data.max:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
if data.currency is None and (
|
if not data.currency:
|
||||||
round(data.min) != data.min or round(data.max) != data.max or data.min < 1
|
if round(data.min) != data.min or round(data.max) != data.max or data.min < 1:
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
@ -94,20 +131,20 @@ async def api_link_create_or_update(
|
||||||
if data.webhook_headers:
|
if data.webhook_headers:
|
||||||
try:
|
try:
|
||||||
json.loads(data.webhook_headers)
|
json.loads(data.webhook_headers)
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Invalid JSON in webhook_headers.",
|
detail="Invalid JSON in webhook_headers.",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
if data.webhook_body:
|
if data.webhook_body:
|
||||||
try:
|
try:
|
||||||
json.loads(data.webhook_body)
|
json.loads(data.webhook_body)
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Invalid JSON in webhook_body.",
|
detail="Invalid JSON in webhook_body.",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
) from exc
|
||||||
|
|
||||||
# database only allows int4 entries for min and max. For fiat currencies,
|
# database only allows int4 entries for min and max. For fiat currencies,
|
||||||
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||||
|
|
@ -115,12 +152,41 @@ async def api_link_create_or_update(
|
||||||
data.min *= data.fiat_base_multiplier
|
data.min *= data.fiat_base_multiplier
|
||||||
data.max *= data.fiat_base_multiplier
|
data.max *= data.fiat_base_multiplier
|
||||||
|
|
||||||
if data.success_url is not None and not data.success_url.startswith("https://"):
|
if (
|
||||||
|
data.success_url
|
||||||
|
and data.success_url != ""
|
||||||
|
and not data.success_url.startswith("https://")
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Success URL must be secure https://...",
|
detail="Success URL must be secure https://...",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if data.username and not re.match("^[a-z0-9-_.]{1,210}$", data.username):
|
||||||
|
raise HTTPException(
|
||||||
|
detail=f"Invalid username: {data.username}. "
|
||||||
|
"Only letters a-z0-9-_. allowed, min 1 and max 210 characters!",
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# if wallet is not provided, use the wallet of the key
|
||||||
|
if not data.wallet:
|
||||||
|
data.wallet = key_info.wallet.id
|
||||||
|
|
||||||
|
new_wallet = await get_wallet(data.wallet)
|
||||||
|
if not new_wallet:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# admins are allowed to create/edit paylinks belonging 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:
|
||||||
|
raise HTTPException(
|
||||||
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
if link_id:
|
if link_id:
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
|
|
@ -129,20 +195,27 @@ async def api_link_create_or_update(
|
||||||
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.wallet != wallet.wallet.id:
|
if data.username and data.username != link.username:
|
||||||
raise HTTPException(
|
await check_username_exists(data.username)
|
||||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
|
||||||
)
|
|
||||||
|
|
||||||
link = await update_pay_link(**data.dict(), link_id=link_id)
|
for k, v in data.dict().items():
|
||||||
|
setattr(link, k, v)
|
||||||
|
|
||||||
|
link = await update_pay_link(link)
|
||||||
else:
|
else:
|
||||||
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
|
if data.username:
|
||||||
assert link
|
await check_username_exists(data.username)
|
||||||
return {**link.dict(), "lnurl": link.lnurl(request)}
|
|
||||||
|
link = await create_pay_link(data)
|
||||||
|
|
||||||
|
link.lnurl = check_lnurl_encode(req, link)
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.delete("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_link_delete(
|
||||||
|
link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
) -> SimpleStatus:
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
|
|
@ -150,31 +223,34 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
if link.wallet != wallet.wallet.id:
|
# admins are allowed to delete paylinks belonging 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:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_pay_link(link_id)
|
await delete_pay_link(link_id)
|
||||||
return {"success": True}
|
return SimpleStatus(success=True, message="Deleted Pay link")
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.get("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||||
async def api_check_fiat_rate(currency):
|
async def api_get_or_create_settings() -> LnurlpSettings:
|
||||||
|
return await get_or_create_lnurlp_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_api_router.put("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||||
|
async def api_update_settings(data: LnurlpSettings) -> LnurlpSettings:
|
||||||
try:
|
try:
|
||||||
rate = await get_fiat_rate_satoshis(currency)
|
parse_nostr_private_key(data.nostr_private_key)
|
||||||
except AssertionError:
|
except Exception as exc:
|
||||||
rate = None
|
raise HTTPException(
|
||||||
|
detail="Invalid Nostr private key.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
return {"rate": rate}
|
) from exc
|
||||||
|
return await update_lnurlp_settings(data)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.delete("/api/v1", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.delete("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||||
async def api_stop(wallet: WalletTypeInfo = Depends(check_admin)):
|
async def api_delete_settings() -> None:
|
||||||
for t in scheduled_tasks:
|
await delete_lnurlp_settings()
|
||||||
try:
|
|
||||||
t.cancel()
|
|
||||||
except Exception as ex:
|
|
||||||
logger.warning(ex)
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
|
||||||
208
views_lnurl.py
Normal file
208
views_lnurl.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
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,
|
||||||
|
LightningInvoice,
|
||||||
|
LnurlErrorResponse,
|
||||||
|
LnurlPayActionResponse,
|
||||||
|
LnurlPayMetadata,
|
||||||
|
LnurlPayResponse,
|
||||||
|
LnurlPaySuccessActionTag,
|
||||||
|
Max144Str,
|
||||||
|
MessageAction,
|
||||||
|
MilliSatoshi,
|
||||||
|
UrlAction,
|
||||||
|
)
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
|
from .crud import (
|
||||||
|
get_address_data,
|
||||||
|
get_or_create_lnurlp_settings,
|
||||||
|
get_pay_link,
|
||||||
|
update_pay_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
lnurlp_lnurl_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_lnurl_router.get(
|
||||||
|
"/api/v1/lnurl/cb/{link_id}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
name="lnurlp.api_lnurl_callback",
|
||||||
|
)
|
||||||
|
async def api_lnurl_callback(
|
||||||
|
request: Request,
|
||||||
|
link_id: str,
|
||||||
|
amount: int = Query(...),
|
||||||
|
webhook_data: str = Query(None),
|
||||||
|
) -> LnurlErrorResponse | LnurlPayActionResponse:
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
|
)
|
||||||
|
link.served_pr = 1
|
||||||
|
await update_pay_link(link)
|
||||||
|
minimum = 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
|
||||||
|
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||||
|
minimum = rate * 995 * link.min
|
||||||
|
maximum = rate * 1010 * link.max
|
||||||
|
else:
|
||||||
|
minimum = link.min * 1000
|
||||||
|
maximum = link.max * 1000
|
||||||
|
|
||||||
|
amount = amount
|
||||||
|
if amount < minimum:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=f"Amount {amount} is smaller than minimum {minimum}."
|
||||||
|
)
|
||||||
|
|
||||||
|
elif amount > maximum:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=f"Amount {amount} is greater than maximum {maximum}."
|
||||||
|
)
|
||||||
|
|
||||||
|
comment = request.query_params.get("comment")
|
||||||
|
if len(comment or "") > link.comment_chars:
|
||||||
|
return LnurlErrorResponse(
|
||||||
|
reason=(
|
||||||
|
f"Got a comment with {len(comment or '')} characters, "
|
||||||
|
f"but can only accept {link.comment_chars}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
"tag": "lnurlp",
|
||||||
|
"link": link.id,
|
||||||
|
"extra": request.query_params.get("amount"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if comment:
|
||||||
|
extra["comment"] = comment
|
||||||
|
|
||||||
|
if webhook_data:
|
||||||
|
extra["webhook_data"] = webhook_data
|
||||||
|
|
||||||
|
# nip 57
|
||||||
|
nostr = request.query_params.get("nostr")
|
||||||
|
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))
|
||||||
|
|
||||||
|
# we take the zap request as the description instead of the metadata if present
|
||||||
|
unhashed_description = nostr.encode() if nostr else metadata.encode()
|
||||||
|
|
||||||
|
payment = 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))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.success_text:
|
||||||
|
message = parse_obj_as(Max144Str, link.success_text)
|
||||||
|
return LnurlPayActionResponse(
|
||||||
|
pr=invoice,
|
||||||
|
successAction=MessageAction(message=message),
|
||||||
|
disposable=link.disposable,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LnurlPayActionResponse(pr=invoice, disposable=link.disposable)
|
||||||
|
|
||||||
|
|
||||||
|
@lnurlp_lnurl_router.get(
|
||||||
|
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
|
||||||
|
name="lnurlp.api_lnurl_response.deprecated",
|
||||||
|
deprecated=True,
|
||||||
|
)
|
||||||
|
@lnurlp_lnurl_router.get(
|
||||||
|
"/{link_id}",
|
||||||
|
name="lnurlp.api_lnurl_response",
|
||||||
|
)
|
||||||
|
async def api_lnurl_response(
|
||||||
|
request: Request, link_id: str, webhook_data: str | None = Query(None)
|
||||||
|
) -> LnurlPayResponse:
|
||||||
|
link = await get_pay_link(link_id)
|
||||||
|
if not link:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
|
)
|
||||||
|
link.served_meta = 1
|
||||||
|
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))
|
||||||
|
|
||||||
|
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(
|
||||||
|
callback=callback_url,
|
||||||
|
minSendable=MilliSatoshi(round(link.min * rate) * 1000),
|
||||||
|
maxSendable=MilliSatoshi(round(link.max * rate) * 1000),
|
||||||
|
metadata=LnurlPayMetadata(json.dumps(metadata)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if link.comment_chars > 0:
|
||||||
|
res.commentAllowed = link.comment_chars
|
||||||
|
|
||||||
|
if link.zaps:
|
||||||
|
settings = await get_or_create_lnurlp_settings()
|
||||||
|
res.allowsNostr = True
|
||||||
|
res.nostrPubkey = settings.public_key
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# redirected from /.well-known/lnurlp
|
||||||
|
@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
|
||||||
|
async def lnaddress(
|
||||||
|
username: str, request: Request
|
||||||
|
) -> LnurlPayResponse | LnurlErrorResponse:
|
||||||
|
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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue