diff --git a/lnbits/extensions/lnurlpos/README.md b/lnbits/extensions/lnurlpos/README.md new file mode 100644 index 00000000..e7713055 --- /dev/null +++ b/lnbits/extensions/lnurlpos/README.md @@ -0,0 +1,3 @@ +# LNURLPoS + +For offline LNURL PoS devices diff --git a/lnbits/extensions/lnurlpos/__init__.py b/lnbits/extensions/lnurlpos/__init__.py new file mode 100644 index 00000000..4c86c827 --- /dev/null +++ b/lnbits/extensions/lnurlpos/__init__.py @@ -0,0 +1,20 @@ +import asyncio +from fastapi import APIRouter, FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.routing import Mount +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_lnurlpos") + +lnurlpos_ext: APIRouter = APIRouter(prefix="/lnurlpos", tags=["lnurlpos"]) + + +def lnurlpos_renderer(): + return template_renderer(["lnbits/extensions/lnurlpos/templates"]) + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa diff --git a/lnbits/extensions/lnurlpos/config.json b/lnbits/extensions/lnurlpos/config.json new file mode 100644 index 00000000..2688e5a5 --- /dev/null +++ b/lnbits/extensions/lnurlpos/config.json @@ -0,0 +1,6 @@ +{ + "name": "LNURLPoS", + "short_description": "For offline LNURL PoS systems", + "icon": "point_of_sale", + "contributors": ["arcbtc"] +} diff --git a/lnbits/extensions/lnurlpos/crud.py b/lnbits/extensions/lnurlpos/crud.py new file mode 100644 index 00000000..5a85fa33 --- /dev/null +++ b/lnbits/extensions/lnurlpos/crud.py @@ -0,0 +1,113 @@ +from datetime import datetime +from typing import List, Optional, Union +from lnbits.helpers import urlsafe_short_hash +from typing import List, Optional +from . import db +from .models import lnurlposs, lnurlpospayment, createLnurlpos + +###############lnurlposS########################## + + +async def create_lnurlpos( + data: createLnurlpos, +) -> lnurlposs: + print(data) + lnurlpos_id = urlsafe_short_hash() + lnurlpos_key = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO lnurlpos.lnurlposs ( + id, + key, + title, + wallet, + currency + ) + VALUES (?, ?, ?, ?, ?) + """, + (lnurlpos_id, lnurlpos_key, data.title, data.wallet, data.currency), + ) + return await get_lnurlpos(lnurlpos_id) + + +async def update_lnurlpos(lnurlpos_id: str, **kwargs) -> Optional[lnurlposs]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnurlpos.lnurlposs SET {q} WHERE id = ?", + (*kwargs.values(), lnurlpos_id), + ) + row = await db.fetchone( + "SELECT * FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,) + ) + return lnurlposs.from_row(row) if row else None + + +async def get_lnurlpos(lnurlpos_id: str) -> lnurlposs: + row = await db.fetchone( + "SELECT * FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,) + ) + return lnurlposs.from_row(row) if row else None + + +async def get_lnurlposs(wallet_ids: Union[str, List[str]]) -> List[lnurlposs]: + wallet_ids = [wallet_ids] + q = ",".join(["?"] * len(wallet_ids[0])) + rows = await db.fetchall( + f""" + SELECT * FROM lnurlpos.lnurlposs WHERE wallet IN ({q}) + ORDER BY id + """, + (*wallet_ids,), + ) + + return [lnurlposs.from_row(row) for row in rows] + + +async def delete_lnurlpos(lnurlpos_id: str) -> None: + await db.execute("DELETE FROM lnurlpos.lnurlposs WHERE id = ?", (lnurlpos_id,)) + + ########################lnulpos payments########################### + + +async def create_lnurlpospayment( + posid: str, + payload: Optional[str] = None, + pin: Optional[str] = None, + sats: Optional[int] = 0, +) -> lnurlpospayment: + lnurlpospayment_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO lnurlpos.lnurlpospayment ( + id, + posid, + payload, + pin, + sats + ) + VALUES (?, ?, ?, ?, ?) + """, + (lnurlpospayment_id, posid, payload, pin, sats), + ) + return await get_lnurlpospayment(lnurlpospayment_id) + + +async def update_lnurlpospayment( + lnurlpospayment_id: str, **kwargs +) -> Optional[lnurlpospayment]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE lnurlpos.lnurlpospayment SET {q} WHERE id = ?", + (*kwargs.values(), lnurlpospayment_id), + ) + row = await db.fetchone( + "SELECT * FROM lnurlpos.lnurlpospayment WHERE id = ?", (lnurlpospayment_id,) + ) + return lnurlpospayment.from_row(row) if row else None + + +async def get_lnurlpospayment(lnurlpospayment_id: str) -> lnurlpospayment: + row = await db.fetchone( + "SELECT * FROM lnurlpos.lnurlpospayment WHERE id = ?", (lnurlpospayment_id,) + ) + return lnurlpospayment.from_row(row) if row else None diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py new file mode 100644 index 00000000..2cfbc4ba --- /dev/null +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -0,0 +1,108 @@ +import json +import hashlib +import math +from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore +from lnurl.types import LnurlPayMetadata +from lnbits.core.services import create_invoice +from hashlib import md5 +from fastapi import Request +from fastapi.param_functions import Query +from . import lnurlpos_ext +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from http import HTTPStatus +from fastapi.params import Depends +from fastapi.param_functions import Query +from .crud import ( + get_lnurlpos, + create_lnurlpospayment, + get_lnurlpospayment, + update_lnurlpospayment, +) +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + + +@lnurlpos_ext.get("/api/v1/lnurl/{nonce}/{payload}/{pos_id}") +async def lnurl_response( + request: Request, + nonce: str = Query(None), + pos_id: str = Query(None), + payload: str = Query(None), +): + pos = await get_lnurlpos(pos_id) + if not pos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found." + ) + nonce1 = bytes.fromhex(nonce) + payload1 = bytes.fromhex(payload) + h = hashlib.sha256(nonce1) + h.update(pos.key.encode()) + s = h.digest() + res = bytearray(payload1) + for i in range(len(res)): + res[i] = res[i] ^ s[i] + decryptedAmount = float(int.from_bytes(res[2:6], "little") / 100) + decryptedPin = int.from_bytes(res[:2], "little") + if type(decryptedAmount) != float: + + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not an amount.") + price_msat = ( + await fiat_amount_as_satoshis(decryptedAmount, pos.currency) + if pos.currency != "sat" + else pos.currency + ) * 1000 + + lnurlpospayment = await create_lnurlpospayment( + posid=pos.id, payload=payload, sats=price_msat, pin=decryptedPin + ) + if not lnurlpospayment: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment" + ) + + payResponse = { + "tag": "payRequest", + "callback": request.url_for( + "lnurlpos.lnurl_callback", + paymentid=lnurlpospayment.id, + ), + "metadata": LnurlPayMetadata(json.dumps([["text/plain", str(pos.title)]])), + "minSendable": price_msat, + "maxSendable": price_msat, + } + return json.dumps(payResponse) + + +@lnurlpos_ext.get("/api/v1/lnurl/cb/{paymentid}") +async def lnurl_callback(paymentid: str = Query(None)): + lnurlpospayment = await get_lnurlpospayment(paymentid) + pos = await get_lnurlpos(lnurlpospayment.posid) + if not pos: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="lnurlpos not found." + ) + payment_hash, payment_request = await create_invoice( + wallet_id=pos.wallet, + amount=int(lnurlpospayment.sats / 1000), + memo=pos.title, + description_hash=hashlib.sha256( + (LnurlPayMetadata(json.dumps([["text/plain", str(pos.title)]]))).encode( + "utf-8" + ) + ).digest(), + extra={"tag": "lnurlpos"}, + ) + lnurlpospayment = await update_lnurlpospayment( + lnurlpospayment_id=paymentid, payhash=payment_hash + ) + success_action = pos.success_action(paymentid) + + payResponse = { + "pr": payment_request, + "success_action": success_action, + "disposable": False, + "routes": [], + } + return json.dumps(payResponse) diff --git a/lnbits/extensions/lnurlpos/migrations.py b/lnbits/extensions/lnurlpos/migrations.py new file mode 100644 index 00000000..011cb4a3 --- /dev/null +++ b/lnbits/extensions/lnurlpos/migrations.py @@ -0,0 +1,30 @@ +async def m001_initial(db): + """ + Initial lnurlpos table. + """ + + await db.execute( + f""" + CREATE TABLE lnurlpos.lnurlposs ( + id TEXT NOT NULL PRIMARY KEY, + key TEXT NOT NULL, + title TEXT NOT NULL, + wallet TEXT NOT NULL, + currency TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + f""" + CREATE TABLE lnurlpos.lnurlpospayment ( + id TEXT NOT NULL PRIMARY KEY, + posid TEXT NOT NULL, + payhash TEXT, + payload TEXT NOT NULL, + pin INT, + sats INT, + timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) diff --git a/lnbits/extensions/lnurlpos/models.py b/lnbits/extensions/lnurlpos/models.py new file mode 100644 index 00000000..b6924593 --- /dev/null +++ b/lnbits/extensions/lnurlpos/models.py @@ -0,0 +1,65 @@ +import json +from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult +from lnurl.types import LnurlPayMetadata # type: ignore +from sqlite3 import Row +from typing import NamedTuple, Optional, Dict +import shortuuid # type: ignore +from fastapi.param_functions import Query +from pydantic.main import BaseModel +from pydantic import BaseModel +from typing import Optional +from fastapi import FastAPI, Request + + +class createLnurlpos(BaseModel): + title: str + wallet: str + currency: str + + +class lnurlposs(BaseModel): + id: str + key: str + title: str + wallet: str + currency: str + timestamp: str + + @classmethod + def from_row(cls, row: Row) -> "lnurlposs": + return cls(**dict(row)) + + @property + def lnurl(self) -> Lnurl: + url = url_for("lnurlpos.lnurl_response", pos_id=self.id, _external=True) + return lnurl_encode(url) + + @property + def lnurlpay_metadata(self) -> LnurlPayMetadata: + return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) + + def success_action(self, paymentid: str, req: Request) -> Optional[Dict]: + url = url_for( + "lnurlpos.displaypin", + paymentid=paymentid, + ) + return { + "tag": "url", + "description": "Check the attached link", + "url": url, + } + + +class lnurlpospayment(BaseModel): + id: str + posid: str + payhash: str + payload: str + pin: int + sats: int + timestamp: str + + @classmethod + def from_row(cls, row: Row) -> "lnurlpospayment": + return cls(**dict(row)) diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html new file mode 100644 index 00000000..071d6d6c --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/_api_docs.html @@ -0,0 +1,158 @@ + + +

+ Register LNURLPoS devices to recieve payments in your LNbits wallet.
+ Build your own here + https://github.com/arcbtc/LNURLPoS
+ + Created by, Ben Arc +

+
+ + + + + POST /lnurlpos/api/v1/lnurlpos +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/lnurlpos -d '{"title": + <string>, "message":<string>, "currency": + <integer>}' -H "Content-type: application/json" -H "X-Api-Key: + {{user.wallets[0].adminkey }}" + +
+
+
+ + + + PUT + /lnurlpos/api/v1/lnurlpos/<lnurlpos_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ curl -X POST {{ request.url_root + }}api/v1/lnurlpos/<lnurlpos_id> -d ''{"title": <string>, + "message":<string>, "currency": <integer>} -H + "Content-type: application/json" -H "X-Api-Key: + {{user.wallets[0].adminkey }}" + +
+
+
+ + + + + GET + /lnurlpos/api/v1/lnurlpos/<lnurlpos_id> +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET /lnurlpos/api/v1/lnurlposs +
Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json) +
+
+ Returns 200 OK (application/json) +
+ [<lnurlpos_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/lnurlposs -H "X-Api-Key: + {{ user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /lnurlpos/api/v1/lnurlpos/<lnurlpos_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/lnurlpos/<lnurlpos_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" + +
+
+
+
+
diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html new file mode 100644 index 00000000..d8e41832 --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/error.html @@ -0,0 +1,34 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

LNURL-pay not paid

+
+ + +
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html new file mode 100644 index 00000000..e6d8cd8f --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/index.html @@ -0,0 +1,472 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + {% raw %} + New LNURLPoS instance + + + + + + +
+
+
lNURLPoS
+
+ +
+ + + + Export to CSV +
+
+ + + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} LNURLPoS Extension +
+
+ + + {% include "lnurlpos/_api_docs.html" %} + +
+
+ + + +
Copy to LNURLPoS device
+
+ {% raw %} String server = "{{location}}";
+ String posId = "{{settingsDialog.data.id}}";
+ String key = "{{settingsDialog.data.key}}";
+ String currency = "{{settingsDialog.data.currency}}";{% endraw %} +
+
+
+ + + + + + + + + + +
+ Update lnurlpos + Create lnurlpos + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/lnurlpos/templates/lnurlpos/paid.html b/lnbits/extensions/lnurlpos/templates/lnurlpos/paid.html new file mode 100644 index 00000000..c185ecce --- /dev/null +++ b/lnbits/extensions/lnurlpos/templates/lnurlpos/paid.html @@ -0,0 +1,27 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+

{{ pin }}

+
+
+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/lnurlpos/views.py b/lnbits/extensions/lnurlpos/views.py new file mode 100644 index 00000000..68e4ef06 --- /dev/null +++ b/lnbits/extensions/lnurlpos/views.py @@ -0,0 +1,60 @@ +from http import HTTPStatus +import httpx +from collections import defaultdict +from lnbits.decorators import check_user_exists + +from .crud import get_lnurlpos, get_lnurlpospayment +from functools import wraps +from lnbits.core.crud import get_standalone_payment +import hashlib +from lnbits.core.services import check_invoice_status +from lnbits.core.crud import update_payment_status +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from fastapi.params import Depends +from fastapi.param_functions import Query +import random + +from datetime import datetime +from http import HTTPStatus +from . import lnurlpos_ext, lnurlpos_renderer +from lnbits.core.models import User, Payment + +templates = Jinja2Templates(directory="templates") + + +@lnurlpos_ext.get("/") +async def index(request: Request, user: User = Depends(check_user_exists)): + return lnurlpos_renderer().TemplateResponse( + "lnurlpos/index.html", {"request": request, "user": user.dict()} + ) + + +@lnurlpos_ext.get("/{paymentid}") +async def displaypin(request: Request, paymentid: str = Query(None)): + lnurlpospayment = await get_lnurlpospayment(paymentid) + if not lnurlpospayment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="No lmurlpos payment" + ) + pos = await get_lnurlpos(lnurlpospayment.posid) + if not pos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos not found." + ) + + status = await check_invoice_status(pos.wallet, lnurlpospayment.payhash) + + is_paid = not status.pending + if not is_paid: + return lnurlpos_renderer().TemplateResponse( + "lnurlpos/error.html", + {"request": request, "pin": "filler", "not_paid": True}, + ) + + await update_payment_status(checking_id=lnurlpospayment.payhash, pending=True) + return lnurlpos_renderer().TemplateResponse( + "lnurlpos/paid.html", {"request": request, "pin": lnurlpospayment.pin} + ) diff --git a/lnbits/extensions/lnurlpos/views_api.py b/lnbits/extensions/lnurlpos/views_api.py new file mode 100644 index 00000000..21c8dd12 --- /dev/null +++ b/lnbits/extensions/lnurlpos/views_api.py @@ -0,0 +1,94 @@ +import hashlib +from fastapi import FastAPI, Request +from fastapi.params import Depends +from http import HTTPStatus +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from fastapi.params import Depends +from fastapi.param_functions import Query +from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type +from lnbits.core.crud import get_user +from lnbits.core.models import User, Payment +from . import lnurlpos_ext + +from lnbits.extensions.lnurlpos import lnurlpos_ext +from .crud import ( + create_lnurlpos, + update_lnurlpos, + get_lnurlpos, + get_lnurlposs, + delete_lnurlpos, +) +from lnbits.utils.exchange_rates import currencies +from .models import createLnurlpos + + +@lnurlpos_ext.get("/api/v1/currencies") +async def api_list_currencies_available(): + return list(currencies.keys()) + + +#######################lnurlpos########################## + + +@lnurlpos_ext.post("/api/v1/lnurlpos") +@lnurlpos_ext.put("/api/v1/lnurlpos/{lnurlpos_id}") +async def api_lnurlpos_create_or_update( + request: Request, + data: createLnurlpos, + wallet: WalletTypeInfo = Depends(get_key_type), + lnurlpos_id: str = Query(None), +): + if not lnurlpos_id: + lnurlpos = await create_lnurlpos(data) + print(lnurlpos.dict()) + return lnurlpos.dict() + else: + lnurlpos = await update_lnurlpos(data, lnurlpos_id=lnurlpos_id) + return lnurlpos.dict() + + +@lnurlpos_ext.get("/api/v1/lnurlpos") +async def api_lnurlposs_retrieve( + request: Request, wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + try: + return [{**lnurlpos.dict()} for lnurlpos in await get_lnurlposs(wallet_ids)] + except: + return "" + + +@lnurlpos_ext.get("/api/v1/lnurlpos/{lnurlpos_id}") +async def api_lnurlpos_retrieve( + request: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + lnurlpos_id: str = Query(None), +): + lnurlpos = await get_lnurlpos(lnurlpos_id) + if not lnurlpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="lnurlpos does not exist" + ) + if not lnurlpos.lnurl_toggle: + return {**lnurlpos.dict()} + return {**lnurlpos.dict(), **{"lnurl": lnurlpos.lnurl(request=request)}} + + +@lnurlpos_ext.delete("/api/v1/lnurlpos/{lnurlpos_id}") +async def api_lnurlpos_delete( + request: Request, + wallet: WalletTypeInfo = Depends(get_key_type), + lnurlpos_id: str = Query(None), +): + lnurlpos = await get_lnurlpos(lnurlpos_id) + + if not lnurlpos: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist." + ) + + await delete_lnurlpos(lnurlpos_id) + + return "", HTTPStatus.NO_CONTENT