diff --git a/README.md b/README.md index 0832bfb..8957dff 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,8 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ ![view lnurlp](https://i.imgur.com/4n41S7T.jpg) + +3. Optional - add Lightning Address + - attach a username to your lnurlp to create a lightning address + - the LN address format will be username@lnbits-domain-name + - Find out more about the lightning address spec at lightningaddress.com diff --git a/__init__.py b/__init__.py index aa13bb9..4ef4c7d 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,14 @@ lnurlp_static_files = [ "name": "lnurlp_static", } ] + +lnurlp_redirect_paths = [ + { + "from_path": "/.well-known/lnurlp", + "redirect_to_path": "/api/v1/well-known", + } +] + scheduled_tasks: List[asyncio.Task] = [] lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"]) diff --git a/crud.py b/crud.py index 4acb4a4..aeb78d0 100644 --- a/crud.py +++ b/crud.py @@ -1,12 +1,51 @@ +import re from typing import List, Optional, Union from lnbits.helpers import urlsafe_short_hash -from . import db +from . import db # , maindb from .models import CreatePayLinkData, PayLink +# from loguru import logger + + +async def check_lnaddress_update(username: str, id: str) -> bool: + # check no duplicates for lnaddress when updating an username + row = await db.fetchall( + "SELECT username FROM lnurlp.pay_links WHERE username = ? AND id = ?", + (username, id), + ) + if len(row) > 1: + assert False, "Username already exists. Try a different one." + return + else: + return True + + +async def check_lnaddress_not_exists(username: str) -> bool: + # check if lnaddress username exists in the database when creating a new entry + row = await db.fetchall( + "SELECT username FROM lnurlp.pay_links WHERE username = ?", (username,) + ) + if row: + assert False, "Username already exists. Try a different one." + else: + return True + + +async def check_lnaddress_format(username: str) -> bool: + # check username complies with lnaddress specification + if not re.match("^[a-z0-9-_.]{3,15}$", username): + assert False, "Only letters a-z0-9-_. allowed, min 3 and max 15 characters!" + return + return True + async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: + if data.username: + await check_lnaddress_format(data.username) + await check_lnaddress_not_exists(data.username) + link_id = urlsafe_short_hash()[:6] result = await db.execute( @@ -26,9 +65,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: success_url, comment_chars, currency, - fiat_base_multiplier + fiat_base_multiplier, + username + ) - VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( link_id, @@ -44,6 +85,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: data.comment_chars, data.currency, data.fiat_base_multiplier, + data.username, ), ) assert result @@ -53,6 +95,13 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: return link +async def get_address_data(username: str) -> Optional[PayLink]: + row = await db.fetchone( + "SELECT * FROM lnurlp.pay_links WHERE username = ?", (username,) + ) + return PayLink.from_row(row) if row else None + + async def get_pay_link(link_id: str) -> Optional[PayLink]: row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) return PayLink.from_row(row) if row else None @@ -73,7 +122,11 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: return [PayLink.from_row(row) for row in rows] -async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: +async def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: + if "lnaddress" in kwargs: + await check_lnaddress_format(kwargs["lnaddress"]) + await check_lnaddress_update(kwargs["lnaddress"], link_id) + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) @@ -82,7 +135,7 @@ async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: +async def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]: q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) await db.execute( f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) @@ -91,5 +144,5 @@ async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: return PayLink.from_row(row) if row else None -async def delete_pay_link(link_id: int) -> None: +async def delete_pay_link(link_id: str) -> None: await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,)) diff --git a/lnurl.py b/lnurl.py index 918a5bd..0f94f54 100644 --- a/lnurl.py +++ b/lnurl.py @@ -1,6 +1,6 @@ from http import HTTPStatus -from fastapi import Request +from fastapi import Request, Query from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse from starlette.exceptions import HTTPException @@ -8,40 +8,20 @@ from lnbits.core.services import create_invoice from lnbits.utils.exchange_rates import get_fiat_rate_satoshis from . import lnurlp_ext -from .crud import increment_pay_link +from .crud import increment_pay_link, get_pay_link, get_address_data +from loguru import logger +from urllib.parse import urlparse @lnurlp_ext.get( - "/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL) + "/api/v1/lnurl/cb/lnaddr/{link_id}", status_code=HTTPStatus.OK, - name="lnurlp.api_lnurl_response.deprecated", + name="lnurlp.api_lnurl_lnaddr_callback", ) -@lnurlp_ext.get( - "/{link_id}", - status_code=HTTPStatus.OK, - name="lnurlp.api_lnurl_response", -) -async def api_lnurl_response(request: Request, link_id): - link = await increment_pay_link(link_id, served_meta=1) - if not link: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." - ) - - rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 - - resp = LnurlPayResponse( - callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id), - min_sendable=round(link.min * rate) * 1000, - max_sendable=round(link.max * rate) * 1000, - metadata=link.lnurlpay_metadata, - ) - params = resp.dict() - - if link.comment_chars > 0: - params["commentAllowed"] = link.comment_chars - - return params +async def api_lnurl_lnaddr_callback( + request: Request, link_id, amount: int = Query(...) +): + return await api_lnurl_callback(request, link_id, amount, lnaddress=True) @lnurlp_ext.get( @@ -49,7 +29,9 @@ async def api_lnurl_response(request: Request, link_id): status_code=HTTPStatus.OK, name="lnurlp.api_lnurl_callback", ) -async def api_lnurl_callback(request: Request, link_id): +async def api_lnurl_callback( + request: Request, link_id, amount: int = Query(...), lnaddress=False +): link = await increment_pay_link(link_id, served_pr=1) if not link: raise HTTPException( @@ -65,7 +47,7 @@ async def api_lnurl_callback(request: Request, link_id): min = link.min * 1000 max = link.max * 1000 - amount_received = int(request.query_params.get("amount") or 0) + amount_received = amount if amount_received < min: return LnurlErrorResponse( reason=f"Amount {amount_received} is smaller than minimum {min}." @@ -82,25 +64,76 @@ async def api_lnurl_callback(request: Request, link_id): reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" ).dict() + if lnaddress: + # for lnaddress, we have to set this otherwise the metadata won't have the identifier + link.domain = urlparse(str(request.url)).netloc + + extra = { + "tag": "lnurlp", + "link": link.id, + "extra": request.query_params.get("amount"), + } + + if comment: + extra["comment"] = (comment,) + + if lnaddress and link.username and link.domain: + extra["lnaddress"] = f"{link.username}@{link.domain}" + payment_hash, payment_request = await create_invoice( wallet_id=link.wallet, amount=int(amount_received / 1000), memo=link.description, unhashed_description=link.lnurlpay_metadata.encode(), - extra={ - "tag": "lnurlp", - "link": link.id, - "comment": comment, - "extra": request.query_params.get("amount"), - }, + extra=extra, ) success_action = link.success_action(payment_hash) if success_action: resp = LnurlPayActionResponse( - pr=payment_request, success_action=success_action, routes=[] + pr=payment_request, success_action=success_action, routes=[] # type: ignore ) else: - resp = LnurlPayActionResponse(pr=payment_request, routes=[]) + resp = LnurlPayActionResponse(pr=payment_request, routes=[]) # type: ignore return resp.dict() + + +@lnurlp_ext.get( + "/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL) + status_code=HTTPStatus.OK, + name="lnurlp.api_lnurl_response.deprecated", +) +@lnurlp_ext.get( + "/{link_id}", + status_code=HTTPStatus.OK, + name="lnurlp.api_lnurl_response", +) +async def api_lnurl_response(request: Request, link_id, lnaddress=False): + link = await increment_pay_link(link_id, served_meta=1) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist." + ) + + rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 + + if lnaddress: + # for lnaddress, we have to set this otherwise the metadata won't have the identifier + link.domain = urlparse(str(request.url)).netloc + callback = request.url_for("lnurlp.api_lnurl_lnaddr_callback", link_id=link.id) + else: + callback = request.url_for("lnurlp.api_lnurl_callback", link_id=link.id) + + resp = LnurlPayResponse( + callback=callback, + min_sendable=round(link.min * rate) * 1000, # type: ignore + max_sendable=round(link.max * rate) * 1000, # type: ignore + metadata=link.lnurlpay_metadata, + ) + params = resp.dict() + + if link.comment_chars > 0: + params["commentAllowed"] = link.comment_chars + + return params diff --git a/migrations.py b/migrations.py index 1ec85eb..879bec3 100644 --- a/migrations.py +++ b/migrations.py @@ -146,3 +146,10 @@ async def m006_redux(db): ) await db.execute("DROP TABLE lnurlp.pay_links_old") + + +async def m007_add_lnaddress_username(db): + """ + Add headers and body to webhooks + """ + await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;") diff --git a/models.py b/models.py index de66d40..9611d41 100644 --- a/models.py +++ b/models.py @@ -23,6 +23,7 @@ class CreatePayLinkData(BaseModel): success_text: str = Query(None) success_url: str = Query(None) fiat_base_multiplier: int = Query(100, ge=1) + username: str = Query(None) class PayLink(BaseModel): @@ -32,6 +33,8 @@ class PayLink(BaseModel): min: float served_meta: int served_pr: int + username: Optional[str] + domain: Optional[str] webhook_url: Optional[str] webhook_headers: Optional[str] webhook_body: Optional[str] @@ -54,10 +57,6 @@ class PayLink(BaseModel): url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id) return lnurl_encode(url) - @property - def lnurlpay_metadata(self) -> LnurlPayMetadata: - return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) - def success_action(self, payment_hash: str) -> Optional[Dict]: if self.success_url: url: ParseResult = urlparse(self.success_url) @@ -73,3 +72,14 @@ class PayLink(BaseModel): return {"tag": "message", "message": self.success_text} else: return None + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + if self.domain and self.username: + text = f"Payment to {self.username}" + identifier = f"{self.username}@{self.domain}" + metadata = [["text/plain", text], ["text/identifier", identifier]] + else: + metadata = [["text/plain", self.description]] + + return LnurlPayMetadata(json.dumps(metadata)) diff --git a/static/js/index.js b/static/js/index.js index c1372be..c44c4ca 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -9,6 +9,8 @@ var locationPath = [ window.location.pathname ].join('') +var domain = window.location.hostname + var mapPayLink = obj => { obj._data = _.clone(obj) obj.date = Quasar.utils.date.formatDate( @@ -26,6 +28,7 @@ new Vue({ mixins: [windowMixin], data() { return { + domain: domain, currencies: [], fiatRates: {}, checker: null, @@ -90,7 +93,8 @@ new Vue({ : 'do nothing', lnurl: link.lnurl, pay_url: link.pay_url, - print_url: link.print_url + print_url: link.print_url, + username: link.username } this.qrCodeDialog.show = true }, @@ -137,7 +141,8 @@ new Vue({ 'success_text', 'success_url', 'comment_chars', - 'currency' + 'currency', + 'username' ), (value, key) => (key === 'webhook_url' || diff --git a/templates/lnurlp/_api_docs.html b/templates/lnurlp/_api_docs.html index abb37e9..9c07eba 100644 --- a/templates/lnurlp/_api_docs.html +++ b/templates/lnurlp/_api_docs.html @@ -62,7 +62,7 @@ {"description": <string> "amount": <integer> "max": <integer> "min": <integer> "comment_chars": - <integer>}
Returns 201 CREATED (application/json) diff --git a/templates/lnurlp/index.html b/templates/lnurlp/index.html index 3fbd344..1ecd490 100644 --- a/templates/lnurlp/index.html +++ b/templates/lnurlp/index.html @@ -26,11 +26,12 @@ > {% raw %} @@ -149,13 +152,28 @@ > - + filled + dense + v-model.trim="formDialog.data.description" + type="text" + label="Item description *" + > + +
+
+ +
+
+   @ {% raw %} {{domain}} {% endraw %} +
+ +
Dispatches webhook to: {{ qrCodeDialog.data.webhook }}
On success: {{ qrCodeDialog.data.success }}
+ + Lightning Address: {{ qrCodeDialog.data.username}}@{{domain}} +
+

{% endraw %}
diff --git a/views_api.py b/views_api.py index b4af294..7ffed6e 100644 --- a/views_api.py +++ b/views_api.py @@ -1,6 +1,7 @@ import json from asyncio.log import logger from http import HTTPStatus +from urllib.parse import urlparse from fastapi import Depends, Query, Request from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl @@ -17,8 +18,17 @@ from .crud import ( get_pay_link, get_pay_links, update_pay_link, + get_address_data, ) from .models import CreatePayLinkData +from .lnurl import api_lnurl_response + +# 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, lnaddress=True) @lnurlp_ext.get("/api/v1/currencies")