Merge branch 'main' of https://github.com/lnbits/lnurlp
This commit is contained in:
commit
1d98dd1223
29 changed files with 3073 additions and 221 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
|
||||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
|
|
@ -1,10 +1,9 @@
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -34,12 +33,12 @@ jobs:
|
||||||
- name: Create pull request in extensions repo
|
- name: Create pull request in extensions repo
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
repo_name: "${{ github.event.repository.name }}"
|
repo_name: '${{ github.event.repository.name }}'
|
||||||
tag: "${{ github.ref_name }}"
|
tag: '${{ github.ref_name }}'
|
||||||
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
|
||||||
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ 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 }}"
|
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"
|
archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
|
||||||
run: |
|
run: |
|
||||||
cd lnbits-extensions
|
cd lnbits-extensions
|
||||||
git checkout -b $branch
|
git checkout -b $branch
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
||||||
__pycache__
|
__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
|
||||||
|
}
|
||||||
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:
|
||||||
|
poetry run ./node_modules/.bin/prettier --write .
|
||||||
|
pyright:
|
||||||
|
poetry run ./node_modules/.bin/pyright
|
||||||
|
|
||||||
|
mypy:
|
||||||
|
poetry run mypy .
|
||||||
|
|
||||||
|
black:
|
||||||
|
poetry run black .
|
||||||
|
|
||||||
|
ruff:
|
||||||
|
poetry run ruff check . --fix
|
||||||
|
|
||||||
|
checkruff:
|
||||||
|
poetry run ruff check .
|
||||||
|
|
||||||
|
checkprettier:
|
||||||
|
poetry run ./node_modules/.bin/prettier --check .
|
||||||
|
|
||||||
|
checkblack:
|
||||||
|
poetry run black --check .
|
||||||
|
|
||||||
|
checkeditorconfig:
|
||||||
|
editorconfig-checker
|
||||||
|
|
||||||
|
test:
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
DEBUG=true \
|
||||||
|
poetry run pytest
|
||||||
|
install-pre-commit-hook:
|
||||||
|
@echo "Installing pre-commit hook to git"
|
||||||
|
@echo "Uninstall the hook with poetry run pre-commit uninstall"
|
||||||
|
poetry run pre-commit install
|
||||||
|
|
||||||
|
pre-commit:
|
||||||
|
poetry run pre-commit run --all-files
|
||||||
|
|
||||||
|
|
||||||
|
checkbundle:
|
||||||
|
@echo "skipping checkbundle"
|
||||||
36
__init__.py
36
__init__.py
|
|
@ -3,12 +3,11 @@ from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
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 create_permanent_unique_task
|
from .views import lnurlp_generic_router
|
||||||
|
from .views_api import lnurlp_api_router
|
||||||
|
from .views_lnurl import lnurlp_lnurl_router
|
||||||
db = Database("ext_lnurlp")
|
|
||||||
|
|
||||||
lnurlp_static_files = [
|
lnurlp_static_files = [
|
||||||
{
|
{
|
||||||
|
|
@ -26,15 +25,9 @@ lnurlp_redirect_paths = [
|
||||||
|
|
||||||
|
|
||||||
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
|
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
|
||||||
|
lnurlp_ext.include_router(lnurlp_generic_router)
|
||||||
def lnurlp_renderer():
|
lnurlp_ext.include_router(lnurlp_api_router)
|
||||||
return template_renderer(["lnurlp/templates"])
|
lnurlp_ext.include_router(lnurlp_lnurl_router)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
scheduled_tasks: List[asyncio.Task] = []
|
scheduled_tasks: List[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
@ -43,6 +36,19 @@ def lnurlp_stop():
|
||||||
for task in scheduled_tasks:
|
for task in scheduled_tasks:
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
def lnurlp_start():
|
def lnurlp_start():
|
||||||
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
|
|
||||||
task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices)
|
task = create_permanent_unique_task("lnurlp", wait_for_paid_invoices)
|
||||||
scheduled_tasks.append(task)
|
scheduled_tasks.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"lnurlp_ext",
|
||||||
|
"lnurlp_static_files",
|
||||||
|
"lnurlp_redirect_paths",
|
||||||
|
"lnurlp_stop",
|
||||||
|
"lnurlp_start",
|
||||||
|
"db",
|
||||||
|
]
|
||||||
|
|
|
||||||
11
crud.py
11
crud.py
|
|
@ -1,11 +1,13 @@
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from lnbits.helpers import urlsafe_short_hash, insert_query, update_query
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import insert_query, update_query, urlsafe_short_hash
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .models import CreatePayLinkData, LnurlpSettings, PayLink
|
from .models import CreatePayLinkData, LnurlpSettings, PayLink
|
||||||
from .nostr.key import PrivateKey
|
from .nostr.key import PrivateKey
|
||||||
|
|
||||||
|
db = Database("ext_lnurlp")
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
||||||
row = await db.fetchone("SELECT * FROM lnurlp.settings LIMIT 1")
|
row = await db.fetchone("SELECT * FROM lnurlp.settings LIMIT 1")
|
||||||
|
|
@ -14,8 +16,7 @@ async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
||||||
else:
|
else:
|
||||||
settings = LnurlpSettings(nostr_private_key=PrivateKey().hex())
|
settings = LnurlpSettings(nostr_private_key=PrivateKey().hex())
|
||||||
await db.execute(
|
await db.execute(
|
||||||
insert_query("lnurlp.settings", settings),
|
insert_query("lnurlp.settings", settings), (*settings.dict().values(),)
|
||||||
(*settings.dict().values(),)
|
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
@ -23,7 +24,7 @@ async def get_or_create_lnurlp_settings() -> LnurlpSettings:
|
||||||
async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings:
|
async def update_lnurlp_settings(settings: LnurlpSettings) -> LnurlpSettings:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update_query("lnurlp.settings", settings, where=""),
|
update_query("lnurlp.settings", settings, where=""),
|
||||||
(*settings.dict().values(),)
|
(*settings.dict().values(),),
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
Create a static LNURLp or LNaddress people can use to pay.
|
Create a static LNURLp or LNaddress people can use to pay.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,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;"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
32
models.py
32
models.py
|
|
@ -1,15 +1,13 @@
|
||||||
import json
|
import json
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from typing import Dict, Optional
|
from typing import Optional
|
||||||
from urllib.parse import ParseResult, urlparse, urlunparse
|
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
|
from lnurl import encode as lnurl_encode
|
||||||
from lnurl.types import LnurlPayMetadata
|
from lnurl.types import LnurlPayMetadata
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from lnbits.lnurl import encode as lnurl_encode
|
|
||||||
|
|
||||||
from .helpers import parse_nostr_private_key
|
from .helpers import parse_nostr_private_key
|
||||||
from .nostr.key import PrivateKey
|
from .nostr.key import PrivateKey
|
||||||
|
|
||||||
|
|
@ -72,29 +70,13 @@ class PayLink(BaseModel):
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
|
|
||||||
def lnurl(self, req: Request) -> str:
|
def lnurl(self, req: Request) -> str:
|
||||||
url = str(req.url_for("lnurlp.api_lnurl_response", link_id=self.id))
|
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
|
||||||
# Check if url is .onion and change to http
|
url_str = str(url)
|
||||||
if urlparse(url).netloc.endswith(".onion"):
|
if url.netloc.endswith(".onion"):
|
||||||
# change url string scheme to http
|
# change url string scheme to http
|
||||||
url = url.replace("https://", "http://")
|
url_str = url_str.replace("https://", "http://")
|
||||||
|
|
||||||
return lnurl_encode(url)
|
return lnurl_encode(url_str)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||||
|
|
|
||||||
|
|
@ -23,21 +23,25 @@
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
class Encoding(Enum):
|
class Encoding(Enum):
|
||||||
"""Enumeration type to list the various supported encodings."""
|
"""Enumeration type to list the various supported encodings."""
|
||||||
|
|
||||||
BECH32 = 1
|
BECH32 = 1
|
||||||
BECH32M = 2
|
BECH32M = 2
|
||||||
|
|
||||||
|
|
||||||
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
BECH32M_CONST = 0x2bc830a3
|
BECH32M_CONST = 0x2BC830A3
|
||||||
|
|
||||||
|
|
||||||
def bech32_polymod(values):
|
def bech32_polymod(values):
|
||||||
"""Internal function that computes the Bech32 checksum."""
|
"""Internal function that computes the Bech32 checksum."""
|
||||||
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||||
chk = 1
|
chk = 1
|
||||||
for value in values:
|
for value in values:
|
||||||
top = chk >> 25
|
top = chk >> 25
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ value
|
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
chk ^= generator[i] if ((top >> i) & 1) else 0
|
chk ^= generator[i] if ((top >> i) & 1) else 0
|
||||||
return chk
|
return chk
|
||||||
|
|
@ -57,26 +61,29 @@ def bech32_verify_checksum(hrp, data):
|
||||||
return Encoding.BECH32M
|
return Encoding.BECH32M
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def bech32_create_checksum(hrp, data, spec):
|
def bech32_create_checksum(hrp, data, spec):
|
||||||
"""Compute the checksum values given HRP and data."""
|
"""Compute the checksum values given HRP and data."""
|
||||||
values = bech32_hrp_expand(hrp) + data
|
values = bech32_hrp_expand(hrp) + data
|
||||||
const = BECH32M_CONST if spec == Encoding.BECH32M else 1
|
const = BECH32M_CONST if spec == Encoding.BECH32M else 1
|
||||||
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const
|
polymod = bech32_polymod([*values, 0, 0, 0, 0, 0, 0]) ^ const
|
||||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||||
|
|
||||||
|
|
||||||
def bech32_encode(hrp, data, spec):
|
def bech32_encode(hrp, data, spec):
|
||||||
"""Compute a Bech32 string given HRP and data values."""
|
"""Compute a Bech32 string given HRP and data values."""
|
||||||
combined = data + bech32_create_checksum(hrp, data, spec)
|
combined = data + bech32_create_checksum(hrp, data, spec)
|
||||||
return hrp + '1' + ''.join([CHARSET[d] for d in combined])
|
return hrp + "1" + "".join([CHARSET[d] for d in combined])
|
||||||
|
|
||||||
|
|
||||||
def bech32_decode(bech):
|
def bech32_decode(bech):
|
||||||
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
|
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
|
||||||
if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
|
if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (
|
||||||
(bech.lower() != bech and bech.upper() != bech)):
|
bech.lower() != bech and bech.upper() != bech
|
||||||
|
):
|
||||||
return (None, None, None)
|
return (None, None, None)
|
||||||
bech = bech.lower()
|
bech = bech.lower()
|
||||||
pos = bech.rfind('1')
|
pos = bech.rfind("1")
|
||||||
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
|
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
|
||||||
return (None, None, None)
|
return (None, None, None)
|
||||||
if not all(x in CHARSET for x in bech[pos + 1 :]):
|
if not all(x in CHARSET for x in bech[pos + 1 :]):
|
||||||
|
|
@ -88,6 +95,7 @@ def bech32_decode(bech):
|
||||||
return (None, None, None)
|
return (None, None, None)
|
||||||
return (hrp, data[:-6], spec)
|
return (hrp, data[:-6], spec)
|
||||||
|
|
||||||
|
|
||||||
def convertbits(data, frombits, tobits, pad=True):
|
def convertbits(data, frombits, tobits, pad=True):
|
||||||
"""General power-of-2 base conversion."""
|
"""General power-of-2 base conversion."""
|
||||||
acc = 0
|
acc = 0
|
||||||
|
|
@ -114,6 +122,7 @@ def convertbits(data, frombits, tobits, pad=True):
|
||||||
def decode(hrp, addr):
|
def decode(hrp, addr):
|
||||||
"""Decode a segwit address."""
|
"""Decode a segwit address."""
|
||||||
hrpgot, data, spec = bech32_decode(addr)
|
hrpgot, data, spec = bech32_decode(addr)
|
||||||
|
assert data, "Invalid bech32 string"
|
||||||
if hrpgot != hrp:
|
if hrpgot != hrp:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
decoded = convertbits(data[1:], 5, 8, False)
|
decoded = convertbits(data[1:], 5, 8, False)
|
||||||
|
|
@ -123,7 +132,12 @@ def decode(hrp, addr):
|
||||||
return (None, None)
|
return (None, None)
|
||||||
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
|
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M:
|
if (
|
||||||
|
data[0] == 0
|
||||||
|
and spec != Encoding.BECH32
|
||||||
|
or data[0] != 0
|
||||||
|
and spec != Encoding.BECH32M
|
||||||
|
):
|
||||||
return (None, None)
|
return (None, None)
|
||||||
return (data[0], decoded)
|
return (data[0], decoded)
|
||||||
|
|
||||||
|
|
@ -131,7 +145,9 @@ def decode(hrp, addr):
|
||||||
def encode(hrp, witver, witprog):
|
def encode(hrp, witver, witprog):
|
||||||
"""Encode a segwit address."""
|
"""Encode a segwit address."""
|
||||||
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
|
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
|
||||||
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec)
|
bits = convertbits(witprog, 8, 5)
|
||||||
|
assert bits, "Invalid witness program"
|
||||||
|
ret = bech32_encode(hrp, [witver, *bits], spec)
|
||||||
if decode(hrp, ret) == (None, None):
|
if decode(hrp, ret) == (None, None):
|
||||||
return None
|
return None
|
||||||
return ret
|
return ret
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import time
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import List
|
|
||||||
from secp256k1 import PublicKey
|
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from secp256k1 import PublicKey
|
||||||
|
|
||||||
from .message_type import ClientMessageType
|
from .message_type import ClientMessageType
|
||||||
|
|
||||||
|
|
@ -20,14 +21,14 @@ class EventKind(IntEnum):
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Event:
|
class Event:
|
||||||
content: str = None
|
content: Optional[str] = None
|
||||||
public_key: str = None
|
public_key: Optional[str] = None
|
||||||
created_at: int = None
|
created_at: Optional[int] = None
|
||||||
kind: int = EventKind.TEXT_NOTE
|
kind: int = EventKind.TEXT_NOTE
|
||||||
tags: List[List[str]] = field(
|
tags: List[List[str]] = field(
|
||||||
default_factory=list
|
default_factory=list
|
||||||
) # Dataclasses require special handling when the default value is a mutable type
|
) # Dataclasses require special handling when the default value is a mutable type
|
||||||
signature: str = None
|
signature: Optional[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.content is not None and not isinstance(self.content, str):
|
if self.content is not None and not isinstance(self.content, str):
|
||||||
|
|
@ -56,6 +57,9 @@ class Event:
|
||||||
@property
|
@property
|
||||||
def id(self) -> str:
|
def id(self) -> str:
|
||||||
# Always recompute the id to reflect the up-to-date state of the Event
|
# Always recompute the id to reflect the up-to-date state of the Event
|
||||||
|
assert self.public_key, "Event public key is missing"
|
||||||
|
assert self.created_at, "Event created_at is missing"
|
||||||
|
assert self.content, "Event content is missing"
|
||||||
return Event.compute_id(
|
return Event.compute_id(
|
||||||
self.public_key, self.created_at, self.kind, self.tags, self.content
|
self.public_key, self.created_at, self.kind, self.tags, self.content
|
||||||
)
|
)
|
||||||
|
|
@ -69,9 +73,11 @@ class Event:
|
||||||
self.tags.append(["e", event_id])
|
self.tags.append(["e", event_id])
|
||||||
|
|
||||||
def verify(self) -> bool:
|
def verify(self) -> bool:
|
||||||
|
assert self.public_key, "Event public key is missing"
|
||||||
pub_key = PublicKey(
|
pub_key = PublicKey(
|
||||||
bytes.fromhex("02" + self.public_key), True
|
bytes.fromhex("02" + self.public_key), True
|
||||||
) # add 02 for schnorr (bip340)
|
) # add 02 for schnorr (bip340)
|
||||||
|
assert self.signature, "Event signature is missing"
|
||||||
return pub_key.schnorr_verify(
|
return pub_key.schnorr_verify(
|
||||||
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
|
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
|
||||||
)
|
)
|
||||||
|
|
@ -95,9 +101,9 @@ class Event:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EncryptedDirectMessage(Event):
|
class EncryptedDirectMessage(Event):
|
||||||
recipient_pubkey: str = None
|
recipient_pubkey: Optional[str] = None
|
||||||
cleartext_content: str = None
|
cleartext_content: Optional[str] = None
|
||||||
reference_event_id: str = None
|
reference_event_id: Optional[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.content is not None:
|
if self.content is not None:
|
||||||
|
|
@ -121,6 +127,7 @@ class EncryptedDirectMessage(Event):
|
||||||
def id(self) -> str:
|
def id(self) -> str:
|
||||||
if self.content is None:
|
if self.content is None:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
"EncryptedDirectMessage `id` is undefined until its message is encrypted and stored in the `content` field"
|
"EncryptedDirectMessage `id` is undefined until its "
|
||||||
|
"message is encrypted and stored in the `content` field"
|
||||||
)
|
)
|
||||||
return super().id
|
return super().id
|
||||||
|
|
|
||||||
45
nostr/key.py
45
nostr/key.py
|
|
@ -1,13 +1,14 @@
|
||||||
import secrets
|
|
||||||
import base64
|
import base64
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import secp256k1
|
import secp256k1
|
||||||
from cffi import FFI
|
from cffi import FFI
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
from cryptography.hazmat.primitives import padding
|
from cryptography.hazmat.primitives import padding
|
||||||
from hashlib import sha256
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
from .event import EncryptedDirectMessage, Event, EventKind
|
|
||||||
from . import bech32
|
from . import bech32
|
||||||
|
from .event import EncryptedDirectMessage, EventKind
|
||||||
|
|
||||||
|
|
||||||
class PublicKey:
|
class PublicKey:
|
||||||
|
|
@ -21,33 +22,41 @@ class PublicKey:
|
||||||
def hex(self) -> str:
|
def hex(self) -> str:
|
||||||
return self.raw_bytes.hex()
|
return self.raw_bytes.hex()
|
||||||
|
|
||||||
def verify_signed_message_hash(self, hash: str, sig: str) -> bool:
|
def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
|
||||||
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
|
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
|
||||||
return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True)
|
return pk.schnorr_verify(
|
||||||
|
bytes.fromhex(message_hash), bytes.fromhex(sig), None, True
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_npub(cls, npub: str):
|
def from_npub(cls, npub: str):
|
||||||
"""Load a PublicKey from its bech32/npub form"""
|
"""Load a PublicKey from its bech32/npub form"""
|
||||||
hrp, data, spec = bech32.bech32_decode(npub)
|
hrp, data, spec = bech32.bech32_decode(npub)
|
||||||
raw_public_key = bech32.convertbits(data, 5, 8)[:-1]
|
assert data, "Invalid npub"
|
||||||
|
bits = bech32.convertbits(data, 5, 8)
|
||||||
|
assert bits, "Invalid npub"
|
||||||
|
raw_public_key = bits[:-1]
|
||||||
return cls(bytes(raw_public_key))
|
return cls(bytes(raw_public_key))
|
||||||
|
|
||||||
|
|
||||||
class PrivateKey:
|
class PrivateKey:
|
||||||
def __init__(self, raw_secret: bytes = None) -> None:
|
def __init__(self, raw_secret: Optional[bytes] = None) -> None:
|
||||||
if not raw_secret is None:
|
if raw_secret is not None:
|
||||||
self.raw_secret = raw_secret
|
self.raw_secret = raw_secret
|
||||||
else:
|
else:
|
||||||
self.raw_secret = secrets.token_bytes(32)
|
self.raw_secret = secrets.token_bytes(32)
|
||||||
|
|
||||||
sk = secp256k1.PrivateKey(self.raw_secret)
|
sk = secp256k1.PrivateKey(self.raw_secret)
|
||||||
|
assert sk.pubkey, "Invalid public"
|
||||||
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
|
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_nsec(cls, nsec: str):
|
def from_nsec(cls, nsec: str):
|
||||||
"""Load a PrivateKey from its bech32/nsec form"""
|
"""Load a PrivateKey from its bech32/nsec form"""
|
||||||
hrp, data, spec = bech32.bech32_decode(nsec)
|
hrp, data, spec = bech32.bech32_decode(nsec)
|
||||||
raw_secret = bech32.convertbits(data, 5, 8)[:-1]
|
bits = bech32.convertbits(data, 5, 8)
|
||||||
|
assert bits, "Invalid nsec"
|
||||||
|
raw_secret = bits[:-1]
|
||||||
return cls(bytes(raw_secret))
|
return cls(bytes(raw_secret))
|
||||||
|
|
||||||
def bech32(self) -> str:
|
def bech32(self) -> str:
|
||||||
|
|
@ -77,11 +86,13 @@ class PrivateKey:
|
||||||
encryptor = cipher.encryptor()
|
encryptor = cipher.encryptor()
|
||||||
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
|
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
|
||||||
|
|
||||||
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
|
msg = base64.b64encode(encrypted_message).decode()
|
||||||
|
return f"{msg}?iv={base64.b64encode(iv).decode()}"
|
||||||
|
|
||||||
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
|
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
|
||||||
|
assert dm.recipient_pubkey, "Recipient public key must be set"
|
||||||
dm.content = self.encrypt_message(
|
dm.content = self.encrypt_message(
|
||||||
message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey
|
message=dm.cleartext_content or "", public_key_hex=dm.recipient_pubkey
|
||||||
)
|
)
|
||||||
|
|
||||||
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
|
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
|
||||||
|
|
@ -102,12 +113,12 @@ class PrivateKey:
|
||||||
|
|
||||||
return unpadded_data.decode()
|
return unpadded_data.decode()
|
||||||
|
|
||||||
def sign_message_hash(self, hash: bytes) -> str:
|
def sign_message_hash(self, message_hash: bytes) -> str:
|
||||||
sk = secp256k1.PrivateKey(self.raw_secret)
|
sk = secp256k1.PrivateKey(self.raw_secret)
|
||||||
sig = sk.schnorr_sign(hash, None, raw=True)
|
sig = sk.schnorr_sign(message_hash, None, raw=True)
|
||||||
return sig.hex()
|
return sig.hex()
|
||||||
|
|
||||||
def sign_event(self, event: Event) -> None:
|
def sign_event(self, event: EncryptedDirectMessage) -> None:
|
||||||
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
|
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
|
||||||
self.encrypt_dm(event)
|
self.encrypt_dm(event)
|
||||||
if event.public_key is None:
|
if event.public_key is None:
|
||||||
|
|
@ -118,7 +129,9 @@ class PrivateKey:
|
||||||
return self.raw_secret == other.raw_secret
|
return self.raw_secret == other.raw_secret
|
||||||
|
|
||||||
|
|
||||||
def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
|
def mine_vanity_key(
|
||||||
|
prefix: Optional[str] = None, suffix: Optional[str] = None
|
||||||
|
) -> PrivateKey:
|
||||||
if prefix is None and suffix is None:
|
if prefix is None and suffix is None:
|
||||||
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
|
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,18 @@ class ClientMessageType:
|
||||||
REQUEST = "REQ"
|
REQUEST = "REQ"
|
||||||
CLOSE = "CLOSE"
|
CLOSE = "CLOSE"
|
||||||
|
|
||||||
|
|
||||||
class RelayMessageType:
|
class RelayMessageType:
|
||||||
EVENT = "EVENT"
|
EVENT = "EVENT"
|
||||||
NOTICE = "NOTICE"
|
NOTICE = "NOTICE"
|
||||||
END_OF_STORED_EVENTS = "EOSE"
|
END_OF_STORED_EVENTS = "EOSE"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid(type: str) -> bool:
|
def is_valid(relay_type: str) -> bool:
|
||||||
if type == RelayMessageType.EVENT or type == RelayMessageType.NOTICE or type == RelayMessageType.END_OF_STORED_EVENTS:
|
if (
|
||||||
|
relay_type == RelayMessageType.EVENT
|
||||||
|
or relay_type == RelayMessageType.NOTICE
|
||||||
|
or relay_type == RelayMessageType.END_OF_STORED_EVENTS
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
59
package-lock.json
generated
Normal file
59
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "lnurlp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "lnurlp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"pyright": "^1.1.358"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pyright": {
|
||||||
|
"version": "1.1.372",
|
||||||
|
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.372.tgz",
|
||||||
|
"integrity": "sha512-S0XYmTQWK+ha9FTIWviNk91UnbD569wPUCNEltSqtHeTJhbHj5z3LkOKiqXAOvn72BBfylcgpQqyQHsocmQtiQ==",
|
||||||
|
"bin": {
|
||||||
|
"pyright": "index.js",
|
||||||
|
"pyright-langserver": "langserver.index.js"
|
||||||
|
},
|
||||||
|
"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.2.5",
|
||||||
|
"pyright": "^1.1.358"
|
||||||
|
}
|
||||||
|
}
|
||||||
2510
poetry.lock
generated
Normal file
2510
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
97
pyproject.toml
Normal file
97
pyproject.toml
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "lnbits-lnurlp"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||||
|
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10 | ^3.9"
|
||||||
|
lnbits = "*"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
black = "^24.3.0"
|
||||||
|
pytest-asyncio = "^0.21.0"
|
||||||
|
pytest = "^7.3.2"
|
||||||
|
mypy = "^1.5.1"
|
||||||
|
pre-commit = "^3.2.2"
|
||||||
|
ruff = "^0.3.2"
|
||||||
|
types-cffi = "^1.16.0.20240331"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
# exclude = "(nostr/*)"
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = [
|
||||||
|
"lnbits.*",
|
||||||
|
"lnurl.*",
|
||||||
|
"loguru.*",
|
||||||
|
"fastapi.*",
|
||||||
|
"pydantic.*",
|
||||||
|
"pyqrcode.*",
|
||||||
|
"shortuuid.*",
|
||||||
|
"httpx.*",
|
||||||
|
"websocket.*",
|
||||||
|
"secp256k1.*",
|
||||||
|
]
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
async def check_lnaddress_format(username: str) -> bool:
|
|
||||||
# check username complies with lnaddress specification
|
|
||||||
if not re.match("^[a-z0-9-_.]{3,64}$", username):
|
|
||||||
assert False, "Only letters a-z0-9-_. allowed, min 3 and max 64 characters!"
|
|
||||||
return
|
|
||||||
return True
|
|
||||||
|
|
@ -33,9 +33,9 @@ new Vue({
|
||||||
return {
|
return {
|
||||||
settings: [
|
settings: [
|
||||||
{
|
{
|
||||||
"type": "str",
|
type: 'str',
|
||||||
"description": "Nostr private key used to zap",
|
description: 'Nostr private key used to zap',
|
||||||
"name": "nostr_private_key",
|
name: 'nostr_private_key'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
domain: window.location.host,
|
domain: window.location.host,
|
||||||
|
|
|
||||||
41
tasks.py
41
tasks.py
|
|
@ -15,7 +15,7 @@ from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
from .crud import get_or_create_lnurlp_settings, get_pay_link
|
from .crud import get_or_create_lnurlp_settings, get_pay_link
|
||||||
from .models import PayLink
|
from .models import PayLink
|
||||||
from .nostr.event import Event
|
from .nostr.event import EncryptedDirectMessage
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
|
|
@ -40,24 +40,20 @@ async def on_invoice_paid(payment: Payment):
|
||||||
logger.error("Invoice paid. But no pay link id found.")
|
logger.error("Invoice paid. But no pay link id found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
pay_link = await get_pay_link(pay_link_id)
|
pay_link = await get_pay_link(pay_link_id)
|
||||||
if not pay_link:
|
if not pay_link:
|
||||||
logger.error(
|
logger.error(f"Invoice paid. But Pay link `{pay_link_id}` not found.")
|
||||||
f"Invoice paid. But Pay link `{pay_link_id}` not found."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if pay_link.zaps:
|
if pay_link.zaps:
|
||||||
zap_receipt = await send_zap(payment)
|
zap_receipt = await send_zap(payment)
|
||||||
pay_link.zap_receipt = zap_receipt
|
|
||||||
|
|
||||||
await send_webhook(payment, pay_link)
|
await send_webhook(
|
||||||
|
payment, pay_link, zap_receipt.to_message() if zap_receipt else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def send_webhook(payment: Payment, pay_link: PayLink):
|
async def send_webhook(payment: Payment, pay_link: PayLink, zap_receipt=None):
|
||||||
if not pay_link.webhook_url:
|
if not pay_link.webhook_url:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -72,14 +68,18 @@ async def send_webhook(payment: Payment, pay_link: PayLink):
|
||||||
"comment": payment.extra.get("comment"),
|
"comment": payment.extra.get("comment"),
|
||||||
"webhook_data": payment.extra.get("webhook_data") or "",
|
"webhook_data": payment.extra.get("webhook_data") or "",
|
||||||
"lnurlp": pay_link.id,
|
"lnurlp": pay_link.id,
|
||||||
"zap_receipt": pay_link.zap_receipt,
|
"body": (
|
||||||
"body": json.loads(pay_link.webhook_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=40,
|
||||||
)
|
)
|
||||||
await mark_webhook_sent(
|
await mark_webhook_sent(
|
||||||
|
|
@ -129,8 +129,15 @@ async def send_zap(payment: Payment):
|
||||||
tags.append([t, tag[0]])
|
tags.append([t, tag[0]])
|
||||||
tags.append(["bolt11", payment.bolt11])
|
tags.append(["bolt11", payment.bolt11])
|
||||||
tags.append(["description", nostr])
|
tags.append(["description", nostr])
|
||||||
zap_receipt = Event(
|
|
||||||
kind=9735, tags=tags, content=payment.extra.get("comment") or ""
|
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 = EncryptedDirectMessage(
|
||||||
|
kind=9735,
|
||||||
|
recipient_pubkey=pubkey,
|
||||||
|
tags=tags,
|
||||||
|
content=payment.extra.get("comment") or "",
|
||||||
|
cleartext_content=payment.extra.get("comment") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = await get_or_create_lnurlp_settings()
|
settings = await get_or_create_lnurlp_settings()
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,14 @@
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<q-btn unelevated color="primary" @click="formDialog.show = true">New pay link</q-btn>
|
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||||
<lnbits-extension-settings-btn-dialog v-if="this.g.user.admin" :endpoint="endpoint" :options="settings" />
|
>New pay link</q-btn
|
||||||
|
>
|
||||||
|
<lnbits-extension-settings-btn-dialog
|
||||||
|
v-if="this.g.user.admin"
|
||||||
|
:endpoint="endpoint"
|
||||||
|
:options="settings"
|
||||||
|
/>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
|
|
|
||||||
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)
|
||||||
7
toc.md
7
toc.md
|
|
@ -1,22 +1,29 @@
|
||||||
# Terms and Conditions for LNbits Extension
|
# Terms and Conditions for LNbits Extension
|
||||||
|
|
||||||
## 1. Acceptance of Terms
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 7. Contact Information
|
||||||
|
|
||||||
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
||||||
22
views.py
22
views.py
|
|
@ -1,27 +1,33 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
from lnbits.core.models import User
|
|
||||||
from lnbits.decorators import check_user_exists
|
|
||||||
|
|
||||||
from . import lnurlp_ext, lnurlp_renderer
|
|
||||||
from .crud import get_pay_link
|
from .crud import get_pay_link
|
||||||
|
|
||||||
|
lnurlp_generic_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def lnurlp_renderer():
|
||||||
|
return template_renderer(["lnurlp/templates"])
|
||||||
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/", response_class=HTMLResponse)
|
@lnurlp_generic_router.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
return lnurlp_renderer().TemplateResponse(
|
return lnurlp_renderer().TemplateResponse(
|
||||||
"lnurlp/index.html", {"request": request, "user": user.dict()}
|
"lnurlp/index.html", {"request": request, "user": user.dict()}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/link/{link_id}", response_class=HTMLResponse)
|
@lnurlp_generic_router.get("/link/{link_id}", response_class=HTMLResponse)
|
||||||
async def display(request: Request, link_id):
|
async def display(request: Request, link_id):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
if not link:
|
if not link:
|
||||||
|
|
@ -32,7 +38,7 @@ async def display(request: Request, link_id):
|
||||||
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
|
return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
@lnurlp_generic_router.get("/print/{link_id}", response_class=HTMLResponse)
|
||||||
async def print_qr(request: Request, link_id):
|
async def print_qr(request: Request, link_id):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
if not link:
|
if not link:
|
||||||
|
|
|
||||||
102
views_api.py
102
views_api.py
|
|
@ -1,21 +1,25 @@
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from lnbits.core.crud import get_user, get_wallet
|
||||||
|
from lnbits.core.models import WalletTypeInfo
|
||||||
|
from lnbits.decorators import (
|
||||||
|
check_admin,
|
||||||
|
get_key_type,
|
||||||
|
require_admin_key,
|
||||||
|
require_invoice_key,
|
||||||
|
)
|
||||||
|
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
||||||
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core.crud import get_user, get_wallet
|
|
||||||
from lnbits.decorators import WalletTypeInfo, check_admin, get_key_type, require_admin_key, require_invoice_key
|
|
||||||
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
|
|
||||||
|
|
||||||
from . import lnurlp_ext
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_pay_link,
|
create_pay_link,
|
||||||
delete_lnurlp_settings,
|
delete_lnurlp_settings,
|
||||||
delete_pay_link,
|
delete_pay_link,
|
||||||
get_address_data,
|
|
||||||
get_or_create_lnurlp_settings,
|
get_or_create_lnurlp_settings,
|
||||||
get_pay_link,
|
get_pay_link,
|
||||||
get_pay_link_by_username,
|
get_pay_link_by_username,
|
||||||
|
|
@ -23,26 +27,18 @@ from .crud import (
|
||||||
update_lnurlp_settings,
|
update_lnurlp_settings,
|
||||||
update_pay_link,
|
update_pay_link,
|
||||||
)
|
)
|
||||||
from .services import check_lnaddress_format
|
|
||||||
from .helpers import parse_nostr_private_key
|
from .helpers import parse_nostr_private_key
|
||||||
from .lnurl import api_lnurl_response
|
|
||||||
from .models import CreatePayLinkData, LnurlpSettings
|
from .models import CreatePayLinkData, LnurlpSettings
|
||||||
|
|
||||||
|
lnurlp_api_router = APIRouter()
|
||||||
# redirected from /.well-known/lnurlp
|
|
||||||
@lnurlp_ext.get("/api/v1/well-known/{username}")
|
|
||||||
async def lnaddress(username: str, request: Request):
|
|
||||||
address_data = await get_address_data(username)
|
|
||||||
assert address_data, "User not found"
|
|
||||||
return await api_lnurl_response(request, address_data.id, webhook_data=None)
|
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/currencies")
|
@lnurlp_api_router.get("/api/v1/currencies")
|
||||||
async def api_list_currencies_available():
|
async def api_list_currencies_available():
|
||||||
return list(currencies.keys())
|
return list(currencies.keys())
|
||||||
|
|
||||||
|
|
||||||
@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),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
|
|
@ -60,14 +56,17 @@ async def api_links(
|
||||||
for link in await get_pay_links(wallet_ids)
|
for link in await get_pay_links(wallet_ids)
|
||||||
]
|
]
|
||||||
|
|
||||||
except LnurlInvalidUrl:
|
except LnurlInvalidUrl as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
||||||
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
|
detail=(
|
||||||
)
|
"LNURLs need to be delivered over a publicly "
|
||||||
|
"accessible `https` domain or Tor onion."
|
||||||
|
),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_link_retrieve(
|
async def api_link_retrieve(
|
||||||
r: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
r: Request, link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
||||||
):
|
):
|
||||||
|
|
@ -80,7 +79,10 @@ async def api_link_retrieve(
|
||||||
|
|
||||||
link_wallet = await get_wallet(link.wallet)
|
link_wallet = await get_wallet(link.wallet)
|
||||||
|
|
||||||
if link_wallet.user != key_info.wallet.user:
|
# 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
|
||||||
)
|
)
|
||||||
|
|
@ -93,11 +95,12 @@ async def check_username_exists(username: str):
|
||||||
if prev_link:
|
if prev_link:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Username already taken.",
|
detail="Username already taken.",
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.CONFLICT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
|
||||||
@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
@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(
|
||||||
data: CreatePayLinkData,
|
data: CreatePayLinkData,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -119,20 +122,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.
|
||||||
|
|
@ -140,18 +143,21 @@ 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 and data.success_url != "" 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:
|
if data.username and not re.match("^[a-z0-9-_.]{1,210}$", data.username):
|
||||||
try:
|
|
||||||
await check_lnaddress_format(data.username)
|
|
||||||
except AssertionError as ex:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail=f"Invalid username: {ex}", status_code=HTTPStatus.BAD_REQUEST
|
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 wallet is not provided, use the wallet of the key
|
||||||
|
|
@ -164,7 +170,10 @@ async def api_link_create_or_update(
|
||||||
detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN
|
detail="Wallet does not exist.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_wallet.user != key_info.wallet.user:
|
# admins are allowed to create/edit paylinks beloging to regular users
|
||||||
|
user = await get_user(key_info.wallet.user)
|
||||||
|
admin_user = user.admin if user else False
|
||||||
|
if not admin_user and new_wallet.user != key_info.wallet.user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
@ -191,7 +200,7 @@ async def api_link_create_or_update(
|
||||||
return {**link.dict(), "lnurl": link.lnurl(request)}
|
return {**link.dict(), "lnurl": link.lnurl(request)}
|
||||||
|
|
||||||
|
|
||||||
@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: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_link_delete(link_id: str, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||||
link = await get_pay_link(link_id)
|
link = await get_pay_link(link_id)
|
||||||
|
|
||||||
|
|
@ -200,7 +209,10 @@ async def api_link_delete(link_id: str, wallet: WalletTypeInfo = Depends(get_key
|
||||||
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 beloging to regular users
|
||||||
|
user = await get_user(wallet.wallet.user)
|
||||||
|
admin_user = user.admin if user else False
|
||||||
|
if not admin_user and link.wallet != wallet.wallet.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
@ -209,7 +221,7 @@ async def api_link_delete(link_id: str, wallet: WalletTypeInfo = Depends(get_key
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
@lnurlp_api_router.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
||||||
async def api_check_fiat_rate(currency):
|
async def api_check_fiat_rate(currency):
|
||||||
try:
|
try:
|
||||||
rate = await get_fiat_rate_satoshis(currency)
|
rate = await get_fiat_rate_satoshis(currency)
|
||||||
|
|
@ -219,22 +231,22 @@ async def api_check_fiat_rate(currency):
|
||||||
return {"rate": rate}
|
return {"rate": rate}
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get("/api/v1/settings", dependencies=[Depends(check_admin)])
|
@lnurlp_api_router.get("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||||
async def api_get_or_create_settings() -> LnurlpSettings:
|
async def api_get_or_create_settings() -> LnurlpSettings:
|
||||||
return await get_or_create_lnurlp_settings()
|
return await get_or_create_lnurlp_settings()
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.put("/api/v1/settings", dependencies=[Depends(check_admin)])
|
@lnurlp_api_router.put("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||||
async def api_update_settings(data: LnurlpSettings) -> LnurlpSettings:
|
async def api_update_settings(data: LnurlpSettings) -> LnurlpSettings:
|
||||||
try:
|
try:
|
||||||
parse_nostr_private_key(data.nostr_private_key)
|
parse_nostr_private_key(data.nostr_private_key)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
detail="Invalid Nostr private key.", status_code=HTTPStatus.BAD_REQUEST
|
detail="Invalid Nostr private key.", status_code=HTTPStatus.BAD_REQUEST
|
||||||
)
|
) from exc
|
||||||
return await update_lnurlp_settings(data)
|
return await update_lnurlp_settings(data)
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.delete("/api/v1/settings", dependencies=[Depends(check_admin)])
|
@lnurlp_api_router.delete("/api/v1/settings", dependencies=[Depends(check_admin)])
|
||||||
async def api_delete_settings() -> None:
|
async def api_delete_settings() -> None:
|
||||||
await delete_lnurlp_settings()
|
await delete_lnurlp_settings()
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,31 @@
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
from fastapi import Query, Request
|
|
||||||
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
|
||||||
|
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
|
||||||
|
from lnurl.models import MessageAction, UrlAction
|
||||||
|
from lnurl.types import (
|
||||||
|
ClearnetUrl,
|
||||||
|
DebugUrl,
|
||||||
|
LightningInvoice,
|
||||||
|
Max144Str,
|
||||||
|
MilliSatoshi,
|
||||||
|
OnionUrl,
|
||||||
|
)
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
from . import lnurlp_ext
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
|
get_address_data,
|
||||||
get_or_create_lnurlp_settings,
|
get_or_create_lnurlp_settings,
|
||||||
increment_pay_link,
|
increment_pay_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lnurlp_lnurl_router = APIRouter()
|
||||||
|
|
||||||
@lnurlp_ext.get(
|
|
||||||
|
@lnurlp_lnurl_router.get(
|
||||||
"/api/v1/lnurl/cb/{link_id}",
|
"/api/v1/lnurl/cb/{link_id}",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
name="lnurlp.api_lnurl_callback",
|
name="lnurlp.api_lnurl_callback",
|
||||||
|
|
@ -31,23 +41,25 @@ async def api_lnurl_callback(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
|
||||||
)
|
)
|
||||||
min, max = link.min, link.max
|
mininum = link.min
|
||||||
|
maximum = link.max
|
||||||
|
|
||||||
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
|
||||||
if link.currency:
|
if link.currency:
|
||||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||||
min = rate * 995 * link.min
|
mininum = rate * 995 * link.min
|
||||||
max = rate * 1010 * link.max
|
maximum = rate * 1010 * link.max
|
||||||
else:
|
else:
|
||||||
min = link.min * 1000
|
mininum = link.min * 1000
|
||||||
max = link.max * 1000
|
maximum = link.max * 1000
|
||||||
|
|
||||||
amount = amount
|
amount = amount
|
||||||
if amount < min:
|
if amount < mininum:
|
||||||
return LnurlErrorResponse(
|
return LnurlErrorResponse(
|
||||||
reason=f"Amount {amount} is smaller than minimum {min}."
|
reason=f"Amount {amount} is smaller than minimum {min}."
|
||||||
).dict()
|
).dict()
|
||||||
|
|
||||||
elif amount > max:
|
elif amount > maximum:
|
||||||
return LnurlErrorResponse(
|
return LnurlErrorResponse(
|
||||||
reason=f"Amount {amount} is greater than maximum {max}."
|
reason=f"Amount {amount} is greater than maximum {max}."
|
||||||
).dict()
|
).dict()
|
||||||
|
|
@ -72,7 +84,7 @@ async def api_lnurl_callback(
|
||||||
}
|
}
|
||||||
|
|
||||||
if comment:
|
if comment:
|
||||||
extra["comment"] = (comment,)
|
extra["comment"] = comment
|
||||||
|
|
||||||
if webhook_data:
|
if webhook_data:
|
||||||
extra["webhook_data"] = webhook_data
|
extra["webhook_data"] = webhook_data
|
||||||
|
|
@ -88,7 +100,7 @@ async def api_lnurl_callback(
|
||||||
# we take the zap request as the description instead of the metadata if present
|
# we take the zap request as the description instead of the metadata if present
|
||||||
unhashed_description = nostr.encode() if nostr else link.lnurlpay_metadata.encode()
|
unhashed_description = nostr.encode() if nostr else link.lnurlpay_metadata.encode()
|
||||||
|
|
||||||
payment_hash, payment_request = await create_invoice(
|
_, payment_request = await create_invoice(
|
||||||
wallet_id=link.wallet,
|
wallet_id=link.wallet,
|
||||||
amount=int(amount / 1000),
|
amount=int(amount / 1000),
|
||||||
memo=link.description,
|
memo=link.description,
|
||||||
|
|
@ -96,23 +108,29 @@ async def api_lnurl_callback(
|
||||||
extra=extra,
|
extra=extra,
|
||||||
)
|
)
|
||||||
|
|
||||||
success_action = link.success_action(payment_hash)
|
action: Optional[Union[MessageAction, UrlAction]] = None
|
||||||
if success_action:
|
if link.success_url:
|
||||||
resp = LnurlPayActionResponse(
|
url = parse_obj_as(
|
||||||
pr=payment_request, success_action=success_action, routes=[] # type: ignore
|
Union[DebugUrl, OnionUrl, ClearnetUrl], # type: ignore
|
||||||
|
str(link.success_url),
|
||||||
)
|
)
|
||||||
else:
|
desc = parse_obj_as(Max144Str, link.success_text)
|
||||||
resp = LnurlPayActionResponse(pr=payment_request, routes=[]) # type: ignore
|
action = UrlAction(url=url, description=desc)
|
||||||
|
elif link.success_text:
|
||||||
|
message = parse_obj_as(Max144Str, link.success_text)
|
||||||
|
action = MessageAction(message=message)
|
||||||
|
|
||||||
|
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment_request))
|
||||||
|
resp = LnurlPayActionResponse(pr=invoice, successAction=action, routes=[])
|
||||||
return resp.dict()
|
return resp.dict()
|
||||||
|
|
||||||
|
|
||||||
@lnurlp_ext.get(
|
@lnurlp_lnurl_router.get(
|
||||||
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
|
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
name="lnurlp.api_lnurl_response.deprecated",
|
name="lnurlp.api_lnurl_response.deprecated",
|
||||||
)
|
)
|
||||||
@lnurlp_ext.get(
|
@lnurlp_lnurl_router.get(
|
||||||
"/{link_id}",
|
"/{link_id}",
|
||||||
status_code=HTTPStatus.OK,
|
status_code=HTTPStatus.OK,
|
||||||
name="lnurlp.api_lnurl_response",
|
name="lnurlp.api_lnurl_response",
|
||||||
|
|
@ -132,11 +150,15 @@ async def api_lnurl_response(
|
||||||
url = url.include_query_params(webhook_data=webhook_data)
|
url = url.include_query_params(webhook_data=webhook_data)
|
||||||
|
|
||||||
link.domain = request.url.netloc
|
link.domain = request.url.netloc
|
||||||
|
callback_url = parse_obj_as(
|
||||||
|
Union[DebugUrl, OnionUrl, ClearnetUrl], # type: ignore
|
||||||
|
str(url),
|
||||||
|
)
|
||||||
|
|
||||||
resp = LnurlPayResponse(
|
resp = LnurlPayResponse(
|
||||||
callback=str(url),
|
callback=callback_url,
|
||||||
min_sendable=round(link.min * rate) * 1000, # type: ignore
|
minSendable=MilliSatoshi(round(link.min * rate) * 1000),
|
||||||
max_sendable=round(link.max * rate) * 1000, # type: ignore
|
maxSendable=MilliSatoshi(round(link.max * rate) * 1000),
|
||||||
metadata=link.lnurlpay_metadata,
|
metadata=link.lnurlpay_metadata,
|
||||||
)
|
)
|
||||||
params = resp.dict()
|
params = resp.dict()
|
||||||
|
|
@ -149,3 +171,11 @@ async def api_lnurl_response(
|
||||||
params["allowsNostr"] = True
|
params["allowsNostr"] = True
|
||||||
params["nostrPubkey"] = settings.public_key
|
params["nostrPubkey"] = settings.public_key
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
# redirected from /.well-known/lnurlp
|
||||||
|
@lnurlp_lnurl_router.get("/api/v1/well-known/{username}")
|
||||||
|
async def lnaddress(username: str, request: Request):
|
||||||
|
address_data = await get_address_data(username)
|
||||||
|
assert address_data, "User not found"
|
||||||
|
return await api_lnurl_response(request, address_data.id, webhook_data=None)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue