This commit is contained in:
Tiago Vasconcelos 2024-09-10 12:49:53 +01:00
commit 1d98dd1223
29 changed files with 3073 additions and 221 deletions

10
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: lint
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev

View file

@ -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
View file

@ -1 +1,4 @@
__pycache__ __pycache__
node_modules
.mypy_cache
.venv

12
.prettierrc Normal file
View 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
View 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"

View file

@ -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
View file

@ -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

View file

@ -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.

View file

@ -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;"
) )

View file

@ -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:

View file

@ -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,37 +61,41 @@ 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 :]):
return (None, None, None) return (None, None, None)
hrp = bech[:pos] hrp = bech[:pos]
data = [CHARSET.find(x) for x in bech[pos+1:]] data = [CHARSET.find(x) for x in bech[pos + 1 :]]
spec = bech32_verify_checksum(hrp, data) spec = bech32_verify_checksum(hrp, data)
if spec is None: if spec is None:
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

View file

@ -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

View file

@ -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")

View file

@ -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
View 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
View 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

File diff suppressed because it is too large Load diff

97
pyproject.toml Normal file
View 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",
]

View file

@ -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

View file

@ -25,7 +25,7 @@ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
computed: { computed: {
endpoint: function() { endpoint: function () {
return `/lnurlp/api/v1/settings?usr=${this.g.user.id}` return `/lnurlp/api/v1/settings?usr=${this.g.user.id}`
} }
}, },
@ -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,

View file

@ -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()

View file

@ -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
View file

11
tests/test_init.py Normal file
View 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
View file

@ -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].

View file

@ -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:

View file

@ -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()

View file

@ -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)