diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 29d6f7d12..8b6ec6ade 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -6,11 +6,11 @@ from ecdsa import SECP256k1, SigningKey # type: ignore from lnurl import encode as lnurl_encode # type: ignore from typing import List, NamedTuple, Optional, Dict from sqlite3 import Row - +from pydantic import BaseModel from lnbits.settings import WALLET -class User(NamedTuple): +class User(BaseModel): id: str email: str extensions: List[str] = [] @@ -26,7 +26,7 @@ class User(NamedTuple): return w[0] if w else None -class Wallet(NamedTuple): +class Wallet(BaseModel): id: str name: str user: str @@ -73,7 +73,7 @@ class Wallet(NamedTuple): return await get_wallet_payment(self.id, payment_hash) -class Payment(NamedTuple): +class Payment(BaseModel): checking_id: str pending: bool amount: int @@ -161,7 +161,7 @@ class Payment(NamedTuple): await delete_payment(self.checking_id) -class BalanceCheck(NamedTuple): +class BalanceCheck(BaseModel): wallet: str service: str url: str diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 2a905bcf2..debdfa282 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -67,45 +67,30 @@ async def api_payments(): HTTPStatus.OK, ) +class CreateInvoiceData(BaseModel): + amount: int = Query(None, ge=1) + memo: str = None + unit: Optional[str] = None + description_hash: str = None + lnurl_callback: Optional[str] = None + lnurl_balance_check: Optional[str] = None + extra: Optional[dict] = None + webhook: Optional[str] = None @api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "amount": {"type": "number", "min": 0.001, "required": True}, - "memo": { - "type": "string", - "empty": False, - "required": True, - "excludes": "description_hash", - }, - "unit": {"type": "string", "empty": False, "required": False}, - "description_hash": { - "type": "string", - "empty": False, - "required": True, - "excludes": "memo", - }, - "lnurl_callback": {"type": "string", "nullable": True, "required": False}, - "lnurl_balance_check": {"type": "string", "required": False}, - "extra": {"type": "dict", "nullable": True, "required": False}, - "webhook": {"type": "string", "empty": False, "required": False}, - } -) # async def api_payments_create_invoice(amount: List[str] = Query([type: str = Query(None)])): - - -async def api_payments_create_invoice(memo: Union[None, constr(min_length=1)], amount: int): - if "description_hash" in g.data: - description_hash = unhexlify(g.data["description_hash"]) +async def api_payments_create_invoice(data: CreateInvoiceData): + if "description_hash" in data: + description_hash = unhexlify(data.description_hash) memo = "" else: description_hash = b"" - memo = g.data["memo"] + memo = data.memo - if g.data.get("unit") or "sat" == "sat": - amount = g.data["amount"] + if data.unit or "sat" == "sat": + amount = data.amount else: - price_in_sats = await fiat_amount_as_satoshis(g.data["amount"], g.data["unit"]) + price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit) amount = price_in_sats async with db.connect() as conn: @@ -115,31 +100,31 @@ async def api_payments_create_invoice(memo: Union[None, constr(min_length=1)], a amount=amount, memo=memo, description_hash=description_hash, - extra=g.data.get("extra"), - webhook=g.data.get("webhook"), + extra=data.extra, + webhook=data.webhook, conn=conn, ) except InvoiceFailure as e: - return jsonable_encoder({"message": str(e)}), 520 + return {"message": str(e)}, 520 except Exception as exc: raise exc invoice = bolt11.decode(payment_request) lnurl_response: Union[None, bool, str] = None - if g.data.get("lnurl_callback"): + if data.lnurl_callback: if "lnurl_balance_check" in g.data: - save_balance_check(g.wallet.id, g.data["lnurl_balance_check"]) + save_balance_check(g.wallet.id, data.lnurl_balance_check) async with httpx.AsyncClient() as client: try: r = await client.get( - g.data["lnurl_callback"], + data.lnurl_callback, params={ "pr": payment_request, "balanceNotify": url_for( "core.lnurl_balance_notify", - service=urlparse(g.data["lnurl_callback"]).netloc, + service=urlparse(data.lnurl_callback).netloc, wal=g.wallet.id, _external=True, ), @@ -158,15 +143,13 @@ async def api_payments_create_invoice(memo: Union[None, constr(min_length=1)], a lnurl_response = False return ( - jsonable_encoder( { "payment_hash": invoice.payment_hash, "payment_request": payment_request, # maintain backwards compatibility with API clients: "checking_id": invoice.payment_hash, "lnurl_response": lnurl_response, - } - ), + }, HTTPStatus.CREATED, ) @@ -181,97 +164,76 @@ async def api_payments_pay_invoice( payment_request=bolt11, ) except ValueError as e: - return jsonable_encoder({"message": str(e)}), HTTPStatus.BAD_REQUEST + return {"message": str(e)}, HTTPStatus.BAD_REQUEST except PermissionError as e: - return jsonable_encoder({"message": str(e)}), HTTPStatus.FORBIDDEN + return {"message": str(e)}, HTTPStatus.FORBIDDEN except PaymentFailure as e: - return jsonable_encoder({"message": str(e)}), 520 + return {"message": str(e)}, 520 except Exception as exc: raise exc return ( - jsonable_encoder( { "payment_hash": payment_hash, # maintain backwards compatibility with API clients: "checking_id": payment_hash, - } - ), + }, HTTPStatus.CREATED, ) -@core_app.route("/api/v1/payments", methods=["POST"]) -@api_validate_post_request(schema={"out": {"type": "boolean", "required": True}}) -async def api_payments_create(): - if g.data["out"] is True: +@core_app.post("/api/v1/payments") +async def api_payments_create(out: bool = True): + if out is True: return await api_payments_pay_invoice() return await api_payments_create_invoice() +class CreateLNURLData(BaseModel): + description_hash: str + callback: str + amount: int + comment: Optional[str] = None + description: Optional[str] = None -@core_app.route("/api/v1/payments/lnurl", methods=["POST"]) +@core_app.post("/api/v1/payments/lnurl") @api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "description_hash": {"type": "string", "empty": False, "required": True}, - "callback": {"type": "string", "empty": False, "required": True}, - "amount": {"type": "number", "empty": False, "required": True}, - "comment": { - "type": "string", - "nullable": True, - "empty": True, - "required": False, - }, - "description": { - "type": "string", - "nullable": True, - "empty": True, - "required": False, - }, - } -) -async def api_payments_pay_lnurl(): - domain = urlparse(g.data["callback"]).netloc +async def api_payments_pay_lnurl(data: CreateLNURLData): + domain = urlparse(data.callback).netloc async with httpx.AsyncClient() as client: try: r = await client.get( - g.data["callback"], - params={"amount": g.data["amount"], "comment": g.data["comment"]}, + data.callback, + params={"amount": data.amount, "comment": data.comment}, timeout=40, ) if r.is_error: raise httpx.ConnectError except (httpx.ConnectError, httpx.RequestError): return ( - jsonify({"message": f"Failed to connect to {domain}."}), + {"message": f"Failed to connect to {domain}."}, HTTPStatus.BAD_REQUEST, ) params = json.loads(r.text) if params.get("status") == "ERROR": - return ( - jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), + return ({"message": f"{domain} said: '{params.get('reason', '')}'"}, HTTPStatus.BAD_REQUEST, ) invoice = bolt11.decode(params["pr"]) - if invoice.amount_msat != g.data["amount"]: + if invoice.amount_msat != data.amount: return ( - jsonify( { "message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}." - } - ), + }, HTTPStatus.BAD_REQUEST, ) if invoice.description_hash != g.data["description_hash"]: return ( - jsonify( { "message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}." - } - ), + }, HTTPStatus.BAD_REQUEST, ) @@ -279,51 +241,49 @@ async def api_payments_pay_lnurl(): if params.get("successAction"): extra["success_action"] = params["successAction"] - if g.data["comment"]: - extra["comment"] = g.data["comment"] + if data.comment: + extra["comment"] = data.comment payment_hash = await pay_invoice( wallet_id=g.wallet.id, payment_request=params["pr"], - description=g.data.get("description", ""), + description=data.description, extra=extra, ) return ( - jsonify( { "success_action": params.get("successAction"), "payment_hash": payment_hash, # maintain backwards compatibility with API clients: "checking_id": payment_hash, - } - ), + }, HTTPStatus.CREATED, ) -@core_app.route("/api/v1/payments/", methods=["GET"]) +@core_app.get("/api/v1/payments/") @api_check_wallet_key("invoice") async def api_payment(payment_hash): payment = await g.wallet.get_payment(payment_hash) if not payment: - return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND + return {"message": "Payment does not exist."}, HTTPStatus.NOT_FOUND elif not payment.pending: - return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK + return {"paid": True, "preimage": payment.preimage}, HTTPStatus.OK try: await payment.check_pending() except Exception: - return jsonify({"paid": False}), HTTPStatus.OK + return {"paid": False}, HTTPStatus.OK return ( - jsonify({"paid": not payment.pending, "preimage": payment.preimage}), + {"paid": not payment.pending, "preimage": payment.preimage}, HTTPStatus.OK, ) -@core_app.route("/api/v1/payments/sse", methods=["GET"]) +@core_app.get("/api/v1/payments/sse") @api_check_wallet_key("invoice", accept_querystring=True) async def api_payments_sse(): this_wallet_id = g.wallet.id @@ -376,7 +336,7 @@ async def api_payments_sse(): return response -@core_app.route("/api/v1/lnurlscan/", methods=["GET"]) +@core_app.get("/api/v1/lnurlscan/") @api_check_wallet_key("invoice") async def api_lnurlscan(code: str): try: @@ -395,7 +355,7 @@ async def api_lnurlscan(code: str): ) # will proceed with these values else: - return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST + return {"message": "invalid lnurl"}, HTTPStatus.BAD_REQUEST # params is what will be returned to the client params: Dict = {"domain": domain} @@ -411,7 +371,7 @@ async def api_lnurlscan(code: str): r = await client.get(url, timeout=5) if r.is_error: return ( - jsonify({"domain": domain, "message": "failed to get parameters"}), + {"domain": domain, "message": "failed to get parameters"}, HTTPStatus.SERVICE_UNAVAILABLE, ) @@ -419,12 +379,10 @@ async def api_lnurlscan(code: str): data = json.loads(r.text) except json.decoder.JSONDecodeError: return ( - jsonify( { "domain": domain, "message": f"got invalid response '{r.text[:200]}'", - } - ), + }, HTTPStatus.SERVICE_UNAVAILABLE, ) @@ -432,9 +390,7 @@ async def api_lnurlscan(code: str): tag = data["tag"] if tag == "channelRequest": return ( - jsonify( - {"domain": domain, "kind": "channel", "message": "unsupported"} - ), + {"domain": domain, "kind": "channel", "message": "unsupported"}, HTTPStatus.BAD_REQUEST, ) @@ -481,32 +437,24 @@ async def api_lnurlscan(code: str): params.update(commentAllowed=data.get("commentAllowed", 0)) except KeyError as exc: return ( - jsonify( { "domain": domain, "message": f"lnurl JSON response invalid: {exc}", - } - ), + }, HTTPStatus.SERVICE_UNAVAILABLE, ) - - return jsonify(params) + return params -@core_app.route("/api/v1/lnurlauth", methods=["POST"]) +@core_app.post("/api/v1/lnurlauth", methods=["POST"]) @api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "callback": {"type": "string", "required": True}, - } -) -async def api_perform_lnurlauth(): - err = await perform_lnurlauth(g.data["callback"]) +async def api_perform_lnurlauth(callback: str): + err = await perform_lnurlauth(callback) if err: - return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE + return {"reason": err.reason}, HTTPStatus.SERVICE_UNAVAILABLE return "", HTTPStatus.OK @core_app.route("/api/v1/currencies", methods=["GET"]) async def api_list_currencies_available(): - return jsonify(list(currencies.keys())) + return list(currencies.keys()) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 0d1b78a99..12446c5b1 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -4,7 +4,6 @@ from quart import ( g, current_app, abort, - jsonify, request, redirect, render_template, @@ -28,21 +27,21 @@ from ..crud import ( from ..services import redeem_lnurl_withdraw, pay_invoice -@core_app.route("/favicon.ico") +@core_app.get("/favicon.ico") async def favicon(): return await send_from_directory( path.join(core_app.root_path, "static"), "favicon.ico" ) -@core_app.route("/") +@core_app.get("/") async def home(): return await render_template( "core/index.html", lnurl=request.args.get("lightning", None) ) -@core_app.route("/extensions") +@core_app.get("/extensions") @validate_uuids(["usr"], required=True) @check_user_exists() async def extensions(): @@ -66,7 +65,7 @@ async def extensions(): return await render_template("core/extensions.html", user=await get_user(g.user.id)) -@core_app.route("/wallet") +@core_app.get("/wallet") @validate_uuids(["usr", "wal"]) async def wallet(): user_id = request.args.get("usr", type=str) @@ -108,19 +107,18 @@ async def wallet(): ) -@core_app.route("/withdraw") +@core_app.get("/withdraw") @validate_uuids(["usr", "wal"], required=True) async def lnurl_full_withdraw(): user = await get_user(request.args.get("usr")) if not user: - return jsonify({"status": "ERROR", "reason": "User does not exist."}) + return {"status": "ERROR", "reason": "User does not exist."} wallet = user.get_wallet(request.args.get("wal")) if not wallet: - return jsonify({"status": "ERROR", "reason": "Wallet does not exist."}) + return{"status": "ERROR", "reason": "Wallet does not exist."} - return jsonify( - { + return { "tag": "withdrawRequest", "callback": url_for( "core.lnurl_full_withdraw_callback", @@ -136,19 +134,18 @@ async def lnurl_full_withdraw(): "core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True ), } - ) -@core_app.route("/withdraw/cb") +@core_app.get("/withdraw/cb") @validate_uuids(["usr", "wal"], required=True) async def lnurl_full_withdraw_callback(): user = await get_user(request.args.get("usr")) if not user: - return jsonify({"status": "ERROR", "reason": "User does not exist."}) + return {"status": "ERROR", "reason": "User does not exist."} wallet = user.get_wallet(request.args.get("wal")) if not wallet: - return jsonify({"status": "ERROR", "reason": "Wallet does not exist."}) + return {"status": "ERROR", "reason": "Wallet does not exist."} pr = request.args.get("pr") @@ -164,10 +161,10 @@ async def lnurl_full_withdraw_callback(): if balance_notify: await save_balance_notify(wallet.id, balance_notify) - return jsonify({"status": "OK"}) + return {"status": "OK"} -@core_app.route("/deletewallet") +@core_app.get("/deletewallet") @validate_uuids(["usr", "wal"], required=True) @check_user_exists() async def deletewallet(): @@ -186,7 +183,7 @@ async def deletewallet(): return redirect(url_for("core.home")) -@core_app.route("/withdraw/notify/") +@core_app.get("/withdraw/notify/") @validate_uuids(["wal"], required=True) async def lnurl_balance_notify(service: str): bc = await get_balance_check(request.args.get("wal"), service) @@ -194,7 +191,7 @@ async def lnurl_balance_notify(service: str): redeem_lnurl_withdraw(bc.wallet, bc.url) -@core_app.route("/lnurlwallet") +@core_app.get("/lnurlwallet") async def lnurlwallet(): async with db.connect() as conn: account = await create_account(conn=conn) @@ -213,14 +210,13 @@ async def lnurlwallet(): return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id)) -@core_app.route("/manifest/.webmanifest") +@core_app.get("/manifest/.webmanifest") async def manifest(usr: str): user = await get_user(usr) if not user: return "", HTTPStatus.NOT_FOUND - return jsonify( - { + return { "short_name": "LNbits", "name": "LNbits Wallet", "icons": [ @@ -244,6 +240,4 @@ async def manifest(usr: str): "url": "/wallet?usr=" + usr + "&wal=" + wallet.id, } for wallet in user.wallets - ], - } - ) + ],} diff --git a/lnbits/core/views/public_api.py b/lnbits/core/views/public_api.py index 167352acd..d404e293e 100644 --- a/lnbits/core/views/public_api.py +++ b/lnbits/core/views/public_api.py @@ -10,22 +10,22 @@ from ..crud import get_standalone_payment from ..tasks import api_invoice_listeners -@core_app.route("/public/v1/payment/", methods=["GET"]) +@core_app.get("/public/v1/payment/") async def api_public_payment_longpolling(payment_hash): payment = await get_standalone_payment(payment_hash) if not payment: - return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND + return {"message": "Payment does not exist."}, HTTPStatus.NOT_FOUND elif not payment.pending: - return jsonify({"status": "paid"}), HTTPStatus.OK + return {"status": "paid"}, HTTPStatus.OK try: invoice = bolt11.decode(payment.bolt11) expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry) if expiration < datetime.datetime.now(): - return jsonify({"status": "expired"}), HTTPStatus.OK + return {"status": "expired"}, HTTPStatus.OK except: - return jsonify({"message": "Invalid bolt11 invoice."}), HTTPStatus.BAD_REQUEST + return {"message": "Invalid bolt11 invoice."}, HTTPStatus.BAD_REQUEST send_payment, receive_payment = trio.open_memory_channel(0) @@ -38,7 +38,7 @@ async def api_public_payment_longpolling(payment_hash): async for payment in receive_payment: if payment.payment_hash == payment_hash: nonlocal response - response = (jsonify({"status": "paid"}), HTTPStatus.OK) + response = ({"status": "paid"}, HTTPStatus.OK) cancel_scope.cancel() async def timeouter(cancel_scope): @@ -52,4 +52,4 @@ async def api_public_payment_longpolling(payment_hash): if response: return response else: - return jsonify({"message": "timeout"}), HTTPStatus.REQUEST_TIMEOUT + return {"message": "timeout"}, HTTPStatus.REQUEST_TIMEOUT