From 741ecac78be2d0bede04aa5dcbe3a55597da075d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Thu, 28 Mar 2024 08:59:28 +0100 Subject: [PATCH] feat: improve on api structure, add openapi tags (#2295) this logically groups api endpoints and gioves them specific openapi tags. which makes them nice on the `/docs` endpoint and makes the `api.py` more approachable * add wallets list endpoint * remove trailing slashes from endpoints * fixup topup url * fix trailing slash on auth * backwards compatibility --- integration/fragments/init-server.jmx | 2 +- lnbits/app.py | 2 +- lnbits/commands.py | 5 +- lnbits/core/__init__.py | 20 +- lnbits/core/views/admin_api.py | 18 +- lnbits/core/views/api.py | 782 +------------------------- lnbits/core/views/auth_api.py | 22 +- lnbits/core/views/extension_api.py | 271 +++++++++ lnbits/core/views/node_api.py | 14 +- lnbits/core/views/payment_api.py | 473 ++++++++++++++++ lnbits/core/views/public_api.py | 2 +- lnbits/core/views/tinyurl_api.py | 2 +- lnbits/core/views/wallet_api.py | 77 +++ lnbits/core/views/webpush_api.py | 6 +- lnbits/core/views/websocket_api.py | 40 ++ tests/conftest.py | 2 +- tests/core/views/test_admin_api.py | 8 +- tests/core/views/test_api.py | 2 +- 18 files changed, 930 insertions(+), 818 deletions(-) create mode 100644 lnbits/core/views/extension_api.py create mode 100644 lnbits/core/views/payment_api.py create mode 100644 lnbits/core/views/wallet_api.py create mode 100644 lnbits/core/views/websocket_api.py diff --git a/integration/fragments/init-server.jmx b/integration/fragments/init-server.jmx index e7762c18f..d80e34da5 100644 --- a/integration/fragments/init-server.jmx +++ b/integration/fragments/init-server.jmx @@ -363,7 +363,7 @@ vars.put("adminWalletKey", resp.adminkey || 'no-adminkey'); ${port} ${scheme} UTF-8 - /admin/api/v1/topup/ + /admin/api/v1/topup PUT true false diff --git a/lnbits/app.py b/lnbits/app.py index 7800afc21..e67a21933 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -43,7 +43,7 @@ from .commands import migrate_databases from .core import init_core_routers from .core.db import core_app_extra from .core.services import check_admin_settings, check_webpush_settings -from .core.views.api import add_installed_extension +from .core.views.extension_api import add_installed_extension from .core.views.generic import update_installed_extension_state from .extension_manager import ( Extension, diff --git a/lnbits/commands.py b/lnbits/commands.py index b43a6046e..0b7d6edff 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -14,7 +14,10 @@ from packaging import version from lnbits.core.models import Payment, User from lnbits.core.services import check_admin_settings -from lnbits.core.views.api import api_install_extension, api_uninstall_extension +from lnbits.core.views.extension_api import ( + api_install_extension, + api_uninstall_extension, +) from lnbits.settings import settings from lnbits.wallets.base import Wallet diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index 40ae228a1..1ce6a0f6c 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -1,30 +1,38 @@ -from fastapi import APIRouter +from fastapi import APIRouter, FastAPI from .db import core_app_extra, db from .views.admin_api import admin_router from .views.api import api_router from .views.auth_api import auth_router +from .views.extension_api import extension_router # this compat is needed for usermanager extension from .views.generic import generic_router, update_user_extension from .views.node_api import node_router, public_node_router, super_node_router +from .views.payment_api import payment_router from .views.public_api import public_router from .views.tinyurl_api import tinyurl_router +from .views.wallet_api import wallet_router from .views.webpush_api import webpush_router +from .views.websocket_api import websocket_router # backwards compatibility for extensions core_app = APIRouter(tags=["Core"]) -def init_core_routers(app): +def init_core_routers(app: FastAPI): app.include_router(core_app) app.include_router(generic_router) - app.include_router(public_router) - app.include_router(api_router) + app.include_router(auth_router) + app.include_router(admin_router) app.include_router(node_router) + app.include_router(extension_router) app.include_router(super_node_router) app.include_router(public_node_router) - app.include_router(admin_router) + app.include_router(public_router) + app.include_router(payment_router) + app.include_router(wallet_router) + app.include_router(api_router) + app.include_router(websocket_router) app.include_router(tinyurl_router) app.include_router(webpush_router) - app.include_router(auth_router) diff --git a/lnbits/core/views/admin_api.py b/lnbits/core/views/admin_api.py index dffd3123c..d9ad8d0d3 100644 --- a/lnbits/core/views/admin_api.py +++ b/lnbits/core/views/admin_api.py @@ -26,11 +26,11 @@ from lnbits.tasks import invoice_listeners from .. import core_app_extra from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings -admin_router = APIRouter() +admin_router = APIRouter(tags=["Admin UI"], prefix="/admin") @admin_router.get( - "/admin/api/v1/audit", + "/api/v1/audit", name="Audit", description="show the current balance of the node and the LNbits database", dependencies=[Depends(check_admin)], @@ -51,7 +51,7 @@ async def api_auditor(): @admin_router.get( - "/admin/api/v1/monitor", + "/api/v1/monitor", name="Monitor", description="show the current listeners and other monitoring data", dependencies=[Depends(check_admin)], @@ -63,7 +63,7 @@ async def api_monitor(): } -@admin_router.get("/admin/api/v1/settings/", response_model=Optional[AdminSettings]) +@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings]) async def api_get_settings( user: User = Depends(check_admin), ) -> Optional[AdminSettings]: @@ -72,7 +72,7 @@ async def api_get_settings( @admin_router.put( - "/admin/api/v1/settings/", + "/api/v1/settings", status_code=HTTPStatus.OK, ) async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)): @@ -85,7 +85,7 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a @admin_router.delete( - "/admin/api/v1/settings/", + "/api/v1/settings", status_code=HTTPStatus.OK, dependencies=[Depends(check_super_user)], ) @@ -95,7 +95,7 @@ async def api_delete_settings() -> None: @admin_router.get( - "/admin/api/v1/restart/", + "/api/v1/restart", status_code=HTTPStatus.OK, dependencies=[Depends(check_super_user)], ) @@ -105,7 +105,7 @@ async def api_restart_server() -> dict[str, str]: @admin_router.put( - "/admin/api/v1/topup/", + "/api/v1/topup", name="Topup", status_code=HTTPStatus.OK, dependencies=[Depends(check_super_user)], @@ -129,7 +129,7 @@ async def api_topup_balance(data: CreateTopup) -> dict[str, str]: @admin_router.get( - "/admin/api/v1/backup/", + "/api/v1/backup", status_code=HTTPStatus.OK, dependencies=[Depends(check_super_user)], response_class=FileResponse, diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index dc157c8ec..f865ae891 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,73 +1,33 @@ -import asyncio import hashlib import json -import uuid from http import HTTPStatus from io import BytesIO -from math import ceil -from typing import Dict, List, Optional, Union +from typing import Dict, List from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx import pyqrcode from fastapi import ( APIRouter, - Body, Depends, - Header, - Request, - WebSocket, - WebSocketDisconnect, ) from fastapi.exceptions import HTTPException -from fastapi.responses import JSONResponse -from loguru import logger -from sse_starlette.sse import EventSourceResponse from starlette.responses import StreamingResponse -from lnbits import bolt11 -from lnbits.core.db import core_app_extra, db -from lnbits.core.helpers import ( - migrate_extension_database, - stop_extension_background_work, -) from lnbits.core.models import ( BaseWallet, ConversionData, - CreateInvoice, - CreateLnurl, CreateLnurlAuth, CreateWallet, - DecodePayment, - Payment, - PaymentFilters, - PaymentHistoryPoint, - Query, User, Wallet, - WalletType, ) -from lnbits.db import Filters, Page from lnbits.decorators import ( WalletTypeInfo, - check_access_token, - check_admin, check_user_exists, get_key_type, - parse_filters, require_admin_key, - require_invoice_key, ) -from lnbits.extension_manager import ( - CreateExtension, - Extension, - ExtensionRelease, - InstallableExtension, - fetch_github_release_config, - fetch_release_payment_info, - get_valid_extensions, -) -from lnbits.helpers import generate_filter_params_openapi, url_for from lnbits.lnurl import decode as lnurl_decode from lnbits.settings import settings from lnbits.utils.exchange_rates import ( @@ -77,39 +37,16 @@ from lnbits.utils.exchange_rates import ( ) from ..crud import ( - DateTrunc, - add_installed_extension, create_account, create_wallet, - delete_dbversion, - delete_installed_extension, - delete_wallet, - drop_extension_db, - get_dbversions, - get_installed_extension, - get_payments, - get_payments_history, - get_payments_paginated, - get_standalone_payment, - get_wallet_for_key, - save_balance_check, - update_pending_payments, - update_wallet, ) -from ..services import ( - InvoiceFailure, - PaymentFailure, - check_transaction_status, - create_invoice, - fee_reserve_total, - pay_invoice, - perform_lnurlauth, - websocketManager, - websocketUpdater, -) -from ..tasks import api_invoice_listeners +from ..services import perform_lnurlauth -api_router = APIRouter() +# backwards compatibility for extension +# TODO: remove api_payment and pay_invoice imports from extensions +from .payment_api import api_payment, pay_invoice # noqa: F401 + +api_router = APIRouter(tags=["Core"]) @api_router.get("/api/v1/health", status_code=HTTPStatus.OK) @@ -117,18 +54,6 @@ async def health(): return -@api_router.get("/api/v1/wallet") -async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): - if wallet.wallet_type == WalletType.admin: - return { - "id": wallet.wallet.id, - "name": wallet.wallet.name, - "balance": wallet.wallet.balance_msat, - } - else: - return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat} - - @api_router.get( "/api/v1/wallets", name="Wallets", @@ -138,45 +63,6 @@ async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWalle return [BaseWallet(**w.dict()) for w in user.wallets] -@api_router.put("/api/v1/wallet/{new_name}") -async def api_update_wallet_name( - new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) -): - await update_wallet(wallet.wallet.id, new_name) - return { - "id": wallet.wallet.id, - "name": wallet.wallet.name, - "balance": wallet.wallet.balance_msat, - } - - -@api_router.patch("/api/v1/wallet", response_model=Wallet) -async def api_update_wallet( - name: Optional[str] = Body(None), - currency: Optional[str] = Body(None), - wallet: WalletTypeInfo = Depends(require_admin_key), -): - return await update_wallet(wallet.wallet.id, name, currency) - - -@api_router.delete("/api/v1/wallet") -async def api_delete_wallet( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> None: - await delete_wallet( - user_id=wallet.wallet.user, - wallet_id=wallet.wallet.id, - ) - - -@api_router.post("/api/v1/wallet", response_model=Wallet) -async def api_create_wallet( - data: CreateWallet, - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> Wallet: - return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name) - - @api_router.post("/api/v1/account", response_model=Wallet) async def api_create_account(data: CreateWallet) -> Wallet: if not settings.new_accounts_allowed: @@ -188,394 +74,6 @@ async def api_create_account(data: CreateWallet) -> Wallet: return await create_wallet(user_id=account.id, wallet_name=data.name) -@api_router.get( - "/api/v1/payments", - name="Payment List", - summary="get list of payments", - response_description="list of payments", - response_model=List[Payment], - openapi_extra=generate_filter_params_openapi(PaymentFilters), -) -async def api_payments( - wallet: WalletTypeInfo = Depends(get_key_type), - filters: Filters = Depends(parse_filters(PaymentFilters)), -): - await update_pending_payments(wallet.wallet.id) - return await get_payments( - wallet_id=wallet.wallet.id, - pending=True, - complete=True, - filters=filters, - ) - - -@api_router.get( - "/api/v1/payments/history", - name="Get payments history", - response_model=List[PaymentHistoryPoint], - openapi_extra=generate_filter_params_openapi(PaymentFilters), -) -async def api_payments_history( - wallet: WalletTypeInfo = Depends(get_key_type), - group: DateTrunc = Query("day"), - filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), -): - await update_pending_payments(wallet.wallet.id) - return await get_payments_history(wallet.wallet.id, group, filters) - - -@api_router.get( - "/api/v1/payments/paginated", - name="Payment List", - summary="get paginated list of payments", - response_description="list of payments", - response_model=Page[Payment], - openapi_extra=generate_filter_params_openapi(PaymentFilters), -) -async def api_payments_paginated( - wallet: WalletTypeInfo = Depends(get_key_type), - filters: Filters = Depends(parse_filters(PaymentFilters)), -): - await update_pending_payments(wallet.wallet.id) - page = await get_payments_paginated( - wallet_id=wallet.wallet.id, - pending=True, - complete=True, - filters=filters, - ) - return page - - -async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet): - description_hash = b"" - unhashed_description = b"" - memo = data.memo or settings.lnbits_site_title - if data.description_hash or data.unhashed_description: - if data.description_hash: - try: - description_hash = bytes.fromhex(data.description_hash) - except ValueError: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="'description_hash' must be a valid hex string", - ) - if data.unhashed_description: - try: - unhashed_description = bytes.fromhex(data.unhashed_description) - except ValueError: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="'unhashed_description' must be a valid hex string", - ) - # do not save memo if description_hash or unhashed_description is set - memo = "" - - async with db.connect() as conn: - try: - payment_hash, payment_request = await create_invoice( - wallet_id=wallet.id, - amount=data.amount, - memo=memo, - currency=data.unit, - description_hash=description_hash, - unhashed_description=unhashed_description, - expiry=data.expiry, - extra=data.extra, - webhook=data.webhook, - internal=data.internal, - conn=conn, - ) - # NOTE: we get the checking_id with a seperate query because create_invoice - # does not return it and it would be a big hustle to change its return type - # (used across extensions) - payment_db = await get_standalone_payment(payment_hash, conn=conn) - assert payment_db is not None, "payment not found" - checking_id = payment_db.checking_id - except InvoiceFailure as e: - raise HTTPException(status_code=520, detail=str(e)) - except Exception as exc: - raise exc - - invoice = bolt11.decode(payment_request) - - lnurl_response: Union[None, bool, str] = None - if data.lnurl_callback: - if data.lnurl_balance_check is not None: - await save_balance_check(wallet.id, data.lnurl_balance_check) - - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers) as client: - try: - r = await client.get( - data.lnurl_callback, - params={ - "pr": payment_request, - "balanceNotify": url_for( - f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}", - external=True, - wal=wallet.id, - ), - }, - timeout=10, - ) - if r.is_error: - lnurl_response = r.text - else: - resp = json.loads(r.text) - if resp["status"] != "OK": - lnurl_response = resp["reason"] - else: - lnurl_response = True - except (httpx.ConnectError, httpx.RequestError) as ex: - logger.error(ex) - lnurl_response = False - - return { - "payment_hash": invoice.payment_hash, - "payment_request": payment_request, - # maintain backwards compatibility with API clients: - "checking_id": checking_id, - "lnurl_response": lnurl_response, - } - - -async def api_payments_pay_invoice( - bolt11: str, wallet: Wallet, extra: Optional[dict] = None -): - try: - payment_hash = await pay_invoice( - wallet_id=wallet.id, payment_request=bolt11, extra=extra - ) - except ValueError as e: - raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) - except PermissionError as e: - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) - except PaymentFailure as e: - raise HTTPException(status_code=520, detail=str(e)) - except Exception as exc: - raise exc - - return { - "payment_hash": payment_hash, - # maintain backwards compatibility with API clients: - "checking_id": payment_hash, - } - - -@api_router.post( - "/api/v1/payments", - summary="Create or pay an invoice", - description=""" - This endpoint can be used both to generate and pay a BOLT11 invoice. - To generate a new invoice for receiving funds into the authorized account, - specify at least the first four fields in the POST body: `out: false`, - `amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds - already in the authorized account, specify `out: true` and use the `bolt11` - field to supply the BOLT11 invoice to be paid. - """, - status_code=HTTPStatus.CREATED, -) -async def api_payments_create( - wallet: WalletTypeInfo = Depends(require_invoice_key), - invoiceData: CreateInvoice = Body(...), -): - if invoiceData.out is True and wallet.wallet_type == WalletType.admin: - if not invoiceData.bolt11: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="BOLT11 string is invalid or not given", - ) - return await api_payments_pay_invoice( - invoiceData.bolt11, wallet.wallet, invoiceData.extra - ) # admin key - elif not invoiceData.out: - # invoice key - return await api_payments_create_invoice(invoiceData, wallet.wallet) - else: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="Invoice (or Admin) key required.", - ) - - -@api_router.get("/api/v1/payments/fee-reserve") -async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: - invoice_obj = bolt11.decode(invoice) - if invoice_obj.amount_msat: - response = { - "fee_reserve": fee_reserve_total(invoice_obj.amount_msat), - } - return JSONResponse(response) - else: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Invoice has no amount.", - ) - - -@api_router.post("/api/v1/payments/lnurl") -async def api_payments_pay_lnurl( - data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key) -): - domain = urlparse(data.callback).netloc - - headers = {"User-Agent": settings.user_agent} - async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: - try: - if data.unit and data.unit != "sat": - amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit) - # no msat precision - amount_msat = ceil(amount_msat // 1000) * 1000 - else: - amount_msat = data.amount - r = await client.get( - data.callback, - params={"amount": amount_msat, "comment": data.comment}, - timeout=40, - ) - if r.is_error: - raise httpx.ConnectError("LNURL callback connection error") - r.raise_for_status() - except (httpx.ConnectError, httpx.RequestError): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Failed to connect to {domain}.", - ) - - params = json.loads(r.text) - if params.get("status") == "ERROR": - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} said: '{params.get('reason', '')}'", - ) - - if not params.get("pr"): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"{domain} did not return a payment request.", - ) - - invoice = bolt11.decode(params["pr"]) - if invoice.amount_msat != amount_msat: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=( - ( - f"{domain} returned an invalid invoice. Expected" - f" {amount_msat} msat, got {invoice.amount_msat}." - ), - ), - ) - - extra = {} - - if params.get("successAction"): - extra["success_action"] = params["successAction"] - if data.comment: - extra["comment"] = data.comment - if data.unit and data.unit != "sat": - extra["fiat_currency"] = data.unit - extra["fiat_amount"] = data.amount / 1000 - assert data.description is not None, "description is required" - payment_hash = await pay_invoice( - wallet_id=wallet.wallet.id, - payment_request=params["pr"], - description=data.description, - extra=extra, - ) - - return { - "success_action": params.get("successAction"), - "payment_hash": payment_hash, - # maintain backwards compatibility with API clients: - "checking_id": payment_hash, - } - - -async def subscribe_wallet_invoices(request: Request, wallet: Wallet): - """ - Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse. - Listenes invoming payments for a wallet and yields jsons with payment details. - """ - this_wallet_id = wallet.id - - payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0) - - uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}" - logger.debug(f"adding sse listener for wallet: {uid}") - api_invoice_listeners[uid] = payment_queue - - try: - while True: - if await request.is_disconnected(): - await request.close() - break - payment: Payment = await payment_queue.get() - if payment.wallet_id == this_wallet_id: - logger.debug("sse listener: payment received", payment) - yield dict(data=payment.json(), event="payment-received") - except asyncio.CancelledError: - logger.debug(f"removing listener for wallet {uid}") - except Exception as exc: - logger.error(f"Error in sse: {exc}") - finally: - api_invoice_listeners.pop(uid) - - -@api_router.get("/api/v1/payments/sse") -async def api_payments_sse( - request: Request, wallet: WalletTypeInfo = Depends(get_key_type) -): - return EventSourceResponse( - subscribe_wallet_invoices(request, wallet.wallet), - ping=20, - media_type="text/event-stream", - ) - - -# TODO: refactor this route into a public and admin one -@api_router.get("/api/v1/payments/{payment_hash}") -async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): - # We use X_Api_Key here because we want this call to work with and without keys - # If a valid key is given, we also return the field "details", otherwise not - wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None - - payment = await get_standalone_payment( - payment_hash, wallet_id=wallet.id if wallet else None - ) - if payment is None: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - await check_transaction_status(payment.wallet_id, payment_hash) - payment = await get_standalone_payment( - payment_hash, wallet_id=wallet.id if wallet else None - ) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." - ) - elif not payment.pending: - if wallet and wallet.id == payment.wallet_id: - return {"paid": True, "preimage": payment.preimage, "details": payment} - return {"paid": True, "preimage": payment.preimage} - - try: - await payment.check_status() - except Exception: - if wallet and wallet.id == payment.wallet_id: - return {"paid": False, "details": payment} - return {"paid": False} - - if wallet and wallet.id == payment.wallet_id: - return { - "paid": not payment.pending, - "preimage": payment.preimage, - "details": payment, - } - return {"paid": not payment.pending, "preimage": payment.preimage} - - @api_router.get("/api/v1/lnurlscan/{code}") async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type)): try: @@ -692,23 +190,6 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type return params -@api_router.post("/api/v1/payments/decode", status_code=HTTPStatus.OK) -async def api_payments_decode(data: DecodePayment) -> JSONResponse: - payment_str = data.data - try: - if payment_str[:5] == "LNURL": - url = str(lnurl_decode(payment_str)) - return JSONResponse({"domain": url}) - else: - invoice = bolt11.decode(payment_str) - return JSONResponse(invoice.data) - except Exception as exc: - return JSONResponse( - {"message": f"Failed to decode: {str(exc)}"}, - status_code=HTTPStatus.BAD_REQUEST, - ) - - @api_router.post("/api/v1/lnurlauth") async def api_perform_lnurlauth( data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -763,252 +244,3 @@ async def img(data): "Expires": "0", }, ) - - -@api_router.websocket("/api/v1/ws/{item_id}") -async def websocket_connect(websocket: WebSocket, item_id: str): - await websocketManager.connect(websocket, item_id) - try: - while True: - await websocket.receive_text() - except WebSocketDisconnect: - websocketManager.disconnect(websocket) - - -@api_router.post("/api/v1/ws/{item_id}") -async def websocket_update_post(item_id: str, data: str): - try: - await websocketUpdater(item_id, data) - return {"sent": True, "data": data} - except Exception: - return {"sent": False, "data": data} - - -@api_router.get("/api/v1/ws/{item_id}/{data}") -async def websocket_update_get(item_id: str, data: str): - try: - await websocketUpdater(item_id, data) - return {"sent": True, "data": data} - except Exception: - return {"sent": False, "data": data} - - -@api_router.post("/api/v1/extension") -async def api_install_extension( - data: CreateExtension, - user: User = Depends(check_admin), - access_token: Optional[str] = Depends(check_access_token), -): - release = await InstallableExtension.get_extension_release( - data.ext_id, data.source_repo, data.archive, data.version - ) - if not release: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Release not found" - ) - - if not release.is_version_compatible: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version" - ) - - release.payment_hash = data.payment_hash - ext_info = InstallableExtension( - id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon - ) - - try: - installed_ext = await get_installed_extension(data.ext_id) - ext_info.payments = installed_ext.payments if installed_ext else [] - - await ext_info.download_archive() - - ext_info.extract_archive() - - extension = Extension.from_installable_ext(ext_info) - - db_version = (await get_dbversions()).get(data.ext_id, 0) - await migrate_extension_database(extension, db_version) - - await add_installed_extension(ext_info) - - if extension.is_upgrade_extension: - # call stop while the old routes are still active - await stop_extension_background_work(data.ext_id, user.id, access_token) - - if data.ext_id not in settings.lnbits_deactivated_extensions: - settings.lnbits_deactivated_extensions += [data.ext_id] - - # mount routes for the new version - core_app_extra.register_new_ext_routes(extension) - - if extension.upgrade_hash: - ext_info.nofiy_upgrade() - - return extension - except AssertionError as e: - raise HTTPException(HTTPStatus.BAD_REQUEST, str(e)) - except Exception as ex: - logger.warning(ex) - ext_info.clean_extension_files() - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=( - f"Failed to install extension {ext_info.id} " - f"({ext_info.installed_version})." - ), - ) - - -@api_router.delete("/api/v1/extension/{ext_id}") -async def api_uninstall_extension( - ext_id: str, - user: User = Depends(check_admin), - access_token: Optional[str] = Depends(check_access_token), -): - installable_extensions = await InstallableExtension.get_installable_extensions() - - extensions = [e for e in installable_extensions if e.id == ext_id] - if len(extensions) == 0: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Unknown extension id: {ext_id}", - ) - - # check that other extensions do not depend on this one - for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())): - installed_ext = next( - (ext for ext in installable_extensions if ext.id == valid_ext_id), None - ) - if installed_ext and ext_id in installed_ext.dependencies: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=( - f"Cannot uninstall. Extension '{installed_ext.name}' " - "depends on this one." - ), - ) - - try: - # call stop while the old routes are still active - await stop_extension_background_work(ext_id, user.id, access_token) - - if ext_id not in settings.lnbits_deactivated_extensions: - settings.lnbits_deactivated_extensions += [ext_id] - - for ext_info in extensions: - ext_info.clean_extension_files() - await delete_installed_extension(ext_id=ext_info.id) - - logger.success(f"Extension '{ext_id}' uninstalled.") - except Exception as ex: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) - ) - - -@api_router.get( - "/api/v1/extension/{ext_id}/releases", dependencies=[Depends(check_admin)] -) -async def get_extension_releases(ext_id: str): - try: - extension_releases: List[ExtensionRelease] = ( - await InstallableExtension.get_extension_releases(ext_id) - ) - - installed_ext = await get_installed_extension(ext_id) - if not installed_ext: - return extension_releases - - for release in extension_releases: - payment_info = installed_ext.find_existing_payment(release.pay_link) - if payment_info: - release.paid_sats = payment_info.amount - - return extension_releases - - except Exception as ex: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) - ) - - -@api_router.put("/api/v1/extension/invoice", dependencies=[Depends(check_admin)]) -async def get_extension_invoice(data: CreateExtension): - try: - assert data.cost_sats, "A non-zero amount must be specified" - release = await InstallableExtension.get_extension_release( - data.ext_id, data.source_repo, data.archive, data.version - ) - assert release, "Release not found" - assert release.pay_link, "Pay link not found for release" - - payment_info = await fetch_release_payment_info( - release.pay_link, data.cost_sats - ) - assert payment_info and payment_info.payment_request, "Cannot request invoice" - invoice = bolt11.decode(payment_info.payment_request) - - assert invoice.amount_msat is not None, "Invoic amount is missing" - invoice_amount = int(invoice.amount_msat / 1000) - assert ( - invoice_amount == data.cost_sats - ), f"Wrong invoice amount: {invoice_amount}." - assert ( - payment_info.payment_hash == invoice.payment_hash - ), "Wroong invoice payment hash" - - return payment_info - - except AssertionError as e: - raise HTTPException(HTTPStatus.BAD_REQUEST, str(e)) - except Exception as ex: - logger.warning(ex) - raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice") - - -@api_router.get( - "/api/v1/extension/release/{org}/{repo}/{tag_name}", - dependencies=[Depends(check_admin)], -) -async def get_extension_release(org: str, repo: str, tag_name: str): - try: - config = await fetch_github_release_config(org, repo, tag_name) - if not config: - return {} - - return { - "min_lnbits_version": config.min_lnbits_version, - "is_version_compatible": config.is_version_compatible(), - "warning": config.warning, - } - except Exception as ex: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) - ) - - -@api_router.delete( - "/api/v1/extension/{ext_id}/db", - dependencies=[Depends(check_admin)], -) -async def delete_extension_db(ext_id: str): - try: - db_version = (await get_dbversions()).get(ext_id, None) - if not db_version: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Unknown extension id: {ext_id}", - ) - await drop_extension_db(ext_id=ext_id) - await delete_dbversion(ext_id=ext_id) - logger.success(f"Database removed for extension '{ext_id}'") - except HTTPException as ex: - logger.error(ex) - raise ex - except Exception as ex: - logger.error(ex) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Cannot delete data for extension '{ext_id}'", - ) diff --git a/lnbits/core/views/auth_api.py b/lnbits/core/views/auth_api.py index 5321ed63e..366d69183 100644 --- a/lnbits/core/views/auth_api.py +++ b/lnbits/core/views/auth_api.py @@ -44,15 +44,15 @@ from ..models import ( UserConfig, ) -auth_router = APIRouter() +auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"]) -@auth_router.get("/api/v1/auth", description="Get the authenticated user") +@auth_router.get("", description="Get the authenticated user") async def get_auth_user(user: User = Depends(check_user_exists)) -> User: return user -@auth_router.post("/api/v1/auth", description="Login via the username and password") +@auth_router.post("", description="Login via the username and password") async def login(data: LoginUsernamePassword) -> JSONResponse: if not settings.is_auth_method_allowed(AuthMethods.username_and_password): raise HTTPException( @@ -75,7 +75,7 @@ async def login(data: LoginUsernamePassword) -> JSONResponse: raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") -@auth_router.post("/api/v1/auth/usr", description="Login via the User ID") +@auth_router.post("/usr", description="Login via the User ID") async def login_usr(data: LoginUsr) -> JSONResponse: if not settings.is_auth_method_allowed(AuthMethods.user_id_only): raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.") @@ -93,7 +93,7 @@ async def login_usr(data: LoginUsr) -> JSONResponse: raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") -@auth_router.get("/api/v1/auth/{provider}", description="SSO Provider") +@auth_router.get("/{provider}", description="SSO Provider") async def login_with_sso_provider( request: Request, provider: str, user_id: Optional[str] = None ): @@ -109,7 +109,7 @@ async def login_with_sso_provider( return await provider_sso.get_login_redirect(state=state) -@auth_router.get("/api/v1/auth/{provider}/token", description="Handle OAuth callback") +@auth_router.get("/{provider}/token", description="Handle OAuth callback") async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse: provider_sso = _new_sso(provider) if not provider_sso: @@ -136,7 +136,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons ) -@auth_router.post("/api/v1/auth/logout") +@auth_router.post("/logout") async def logout() -> JSONResponse: response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK) response.delete_cookie("cookie_access_token") @@ -147,7 +147,7 @@ async def logout() -> JSONResponse: return response -@auth_router.post("/api/v1/auth/register") +@auth_router.post("/register") async def register(data: CreateUser) -> JSONResponse: if not settings.is_auth_method_allowed(AuthMethods.username_and_password): raise HTTPException( @@ -176,7 +176,7 @@ async def register(data: CreateUser) -> JSONResponse: raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user.") -@auth_router.put("/api/v1/auth/password") +@auth_router.put("/password") async def update_password( data: UpdateUserPassword, user: User = Depends(check_user_exists) ) -> Optional[User]: @@ -198,7 +198,7 @@ async def update_password( ) -@auth_router.put("/api/v1/auth/update") +@auth_router.put("/update") async def update( data: UpdateUser, user: User = Depends(check_user_exists) ) -> Optional[User]: @@ -218,7 +218,7 @@ async def update( raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user.") -@auth_router.put("/api/v1/auth/first_install") +@auth_router.put("/first_install") async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: if not settings.first_install: raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install") diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py new file mode 100644 index 000000000..db8c04957 --- /dev/null +++ b/lnbits/core/views/extension_api.py @@ -0,0 +1,271 @@ +from typing import ( + List, + Optional, +) + +from bolt11 import decode as bolt11_decode +from fastapi import ( + APIRouter, + Depends, + HTTPException, +) +from fastapi import ( + status as HTTPStatus, +) +from loguru import logger + +from lnbits.core.db import core_app_extra +from lnbits.core.helpers import ( + migrate_extension_database, + stop_extension_background_work, +) +from lnbits.core.models import ( + User, +) +from lnbits.decorators import ( + check_access_token, + check_admin, +) +from lnbits.extension_manager import ( + CreateExtension, + Extension, + ExtensionRelease, + InstallableExtension, + fetch_github_release_config, + fetch_release_payment_info, + get_valid_extensions, +) +from lnbits.settings import settings + +from ..crud import ( + add_installed_extension, + delete_dbversion, + delete_installed_extension, + drop_extension_db, + get_dbversions, + get_installed_extension, +) + +extension_router = APIRouter( + tags=["Extension Managment"], + prefix="/api/v1/extension", +) + + +@extension_router.post("") +async def api_install_extension( + data: CreateExtension, + user: User = Depends(check_admin), + access_token: Optional[str] = Depends(check_access_token), +): + release = await InstallableExtension.get_extension_release( + data.ext_id, data.source_repo, data.archive, data.version + ) + if not release: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Release not found" + ) + + if not release.is_version_compatible: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version" + ) + + release.payment_hash = data.payment_hash + ext_info = InstallableExtension( + id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon + ) + + try: + installed_ext = await get_installed_extension(data.ext_id) + ext_info.payments = installed_ext.payments if installed_ext else [] + + await ext_info.download_archive() + + ext_info.extract_archive() + + extension = Extension.from_installable_ext(ext_info) + + db_version = (await get_dbversions()).get(data.ext_id, 0) + await migrate_extension_database(extension, db_version) + + await add_installed_extension(ext_info) + + if extension.is_upgrade_extension: + # call stop while the old routes are still active + await stop_extension_background_work(data.ext_id, user.id, access_token) + + if data.ext_id not in settings.lnbits_deactivated_extensions: + settings.lnbits_deactivated_extensions += [data.ext_id] + + # mount routes for the new version + core_app_extra.register_new_ext_routes(extension) + + if extension.upgrade_hash: + ext_info.nofiy_upgrade() + + return extension + except AssertionError as e: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(e)) + except Exception as ex: + logger.warning(ex) + ext_info.clean_extension_files() + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=( + f"Failed to install extension {ext_info.id} " + f"({ext_info.installed_version})." + ), + ) + + +@extension_router.delete("/{ext_id}") +async def api_uninstall_extension( + ext_id: str, + user: User = Depends(check_admin), + access_token: Optional[str] = Depends(check_access_token), +): + installable_extensions = await InstallableExtension.get_installable_extensions() + + extensions = [e for e in installable_extensions if e.id == ext_id] + if len(extensions) == 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Unknown extension id: {ext_id}", + ) + + # check that other extensions do not depend on this one + for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())): + installed_ext = next( + (ext for ext in installable_extensions if ext.id == valid_ext_id), None + ) + if installed_ext and ext_id in installed_ext.dependencies: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Cannot uninstall. Extension '{installed_ext.name}' " + "depends on this one." + ), + ) + + try: + # call stop while the old routes are still active + await stop_extension_background_work(ext_id, user.id, access_token) + + if ext_id not in settings.lnbits_deactivated_extensions: + settings.lnbits_deactivated_extensions += [ext_id] + + for ext_info in extensions: + ext_info.clean_extension_files() + await delete_installed_extension(ext_id=ext_info.id) + + logger.success(f"Extension '{ext_id}' uninstalled.") + except Exception as ex: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) + ) + + +@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)]) +async def get_extension_releases(ext_id: str): + try: + extension_releases: List[ExtensionRelease] = ( + await InstallableExtension.get_extension_releases(ext_id) + ) + + installed_ext = await get_installed_extension(ext_id) + if not installed_ext: + return extension_releases + + for release in extension_releases: + payment_info = installed_ext.find_existing_payment(release.pay_link) + if payment_info: + release.paid_sats = payment_info.amount + + return extension_releases + + except Exception as ex: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) + ) + + +@extension_router.put("/invoice", dependencies=[Depends(check_admin)]) +async def get_extension_invoice(data: CreateExtension): + try: + assert data.cost_sats, "A non-zero amount must be specified" + release = await InstallableExtension.get_extension_release( + data.ext_id, data.source_repo, data.archive, data.version + ) + assert release, "Release not found" + assert release.pay_link, "Pay link not found for release" + + payment_info = await fetch_release_payment_info( + release.pay_link, data.cost_sats + ) + assert payment_info and payment_info.payment_request, "Cannot request invoice" + invoice = bolt11_decode(payment_info.payment_request) + + assert invoice.amount_msat is not None, "Invoic amount is missing" + invoice_amount = int(invoice.amount_msat / 1000) + assert ( + invoice_amount == data.cost_sats + ), f"Wrong invoice amount: {invoice_amount}." + assert ( + payment_info.payment_hash == invoice.payment_hash + ), "Wroong invoice payment hash" + + return payment_info + + except AssertionError as e: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(e)) + except Exception as ex: + logger.warning(ex) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice") + + +@extension_router.get( + "/release/{org}/{repo}/{tag_name}", + dependencies=[Depends(check_admin)], +) +async def get_extension_release(org: str, repo: str, tag_name: str): + try: + config = await fetch_github_release_config(org, repo, tag_name) + if not config: + return {} + + return { + "min_lnbits_version": config.min_lnbits_version, + "is_version_compatible": config.is_version_compatible(), + "warning": config.warning, + } + except Exception as ex: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex) + ) + + +@extension_router.delete( + "/{ext_id}/db", + dependencies=[Depends(check_admin)], +) +async def delete_extension_db(ext_id: str): + try: + db_version = (await get_dbversions()).get(ext_id, None) + if not db_version: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Unknown extension id: {ext_id}", + ) + await drop_extension_db(ext_id=ext_id) + await delete_dbversion(ext_id=ext_id) + logger.success(f"Database removed for extension '{ext_id}'") + except HTTPException as ex: + logger.error(ex) + raise ex + except Exception as ex: + logger.error(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Cannot delete data for extension '{ext_id}'", + ) diff --git a/lnbits/core/views/node_api.py b/lnbits/core/views/node_api.py index c12bab74f..917c24936 100644 --- a/lnbits/core/views/node_api.py +++ b/lnbits/core/views/node_api.py @@ -49,12 +49,20 @@ def check_public(): ) -node_router = APIRouter(prefix="/node/api/v1", dependencies=[Depends(check_admin)]) +node_router = APIRouter( + tags=["Node Managment"], + prefix="/node/api/v1", + dependencies=[Depends(check_admin)], +) super_node_router = APIRouter( - prefix="/node/api/v1", dependencies=[Depends(check_super_user)] + tags=["Node Managment"], + prefix="/node/api/v1", + dependencies=[Depends(check_super_user)], ) public_node_router = APIRouter( - prefix="/node/public/api/v1", dependencies=[Depends(check_public)] + tags=["Node Managment"], + prefix="/node/public/api/v1", + dependencies=[Depends(check_public)], ) diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py new file mode 100644 index 000000000..641f72136 --- /dev/null +++ b/lnbits/core/views/payment_api.py @@ -0,0 +1,473 @@ +import asyncio +import json +import uuid +from http import HTTPStatus +from math import ceil +from typing import List, Optional, Union +from urllib.parse import urlparse + +import httpx +from fastapi import ( + APIRouter, + Body, + Depends, + Header, + HTTPException, + Request, +) +from fastapi.responses import JSONResponse +from loguru import logger +from sse_starlette.sse import EventSourceResponse + +from lnbits import bolt11 +from lnbits.core.db import db +from lnbits.core.models import ( + CreateInvoice, + CreateLnurl, + DecodePayment, + Payment, + PaymentFilters, + PaymentHistoryPoint, + Query, + Wallet, + WalletType, +) +from lnbits.db import Filters, Page +from lnbits.decorators import ( + WalletTypeInfo, + get_key_type, + parse_filters, + require_admin_key, + require_invoice_key, +) +from lnbits.helpers import generate_filter_params_openapi, url_for +from lnbits.lnurl import decode as lnurl_decode +from lnbits.settings import settings +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + +from ..crud import ( + DateTrunc, + get_payments, + get_payments_history, + get_payments_paginated, + get_standalone_payment, + get_wallet_for_key, + save_balance_check, + update_pending_payments, +) +from ..services import ( + InvoiceFailure, + PaymentFailure, + check_transaction_status, + create_invoice, + fee_reserve_total, + pay_invoice, +) +from ..tasks import api_invoice_listeners + +payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"]) + + +@payment_router.get( + "", + name="Payment List", + summary="get list of payments", + response_description="list of payments", + response_model=List[Payment], + openapi_extra=generate_filter_params_openapi(PaymentFilters), +) +async def api_payments( + wallet: WalletTypeInfo = Depends(get_key_type), + filters: Filters = Depends(parse_filters(PaymentFilters)), +): + await update_pending_payments(wallet.wallet.id) + return await get_payments( + wallet_id=wallet.wallet.id, + pending=True, + complete=True, + filters=filters, + ) + + +@payment_router.get( + "/history", + name="Get payments history", + response_model=List[PaymentHistoryPoint], + openapi_extra=generate_filter_params_openapi(PaymentFilters), +) +async def api_payments_history( + wallet: WalletTypeInfo = Depends(get_key_type), + group: DateTrunc = Query("day"), + filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), +): + await update_pending_payments(wallet.wallet.id) + return await get_payments_history(wallet.wallet.id, group, filters) + + +@payment_router.get( + "/paginated", + name="Payment List", + summary="get paginated list of payments", + response_description="list of payments", + response_model=Page[Payment], + openapi_extra=generate_filter_params_openapi(PaymentFilters), +) +async def api_payments_paginated( + wallet: WalletTypeInfo = Depends(get_key_type), + filters: Filters = Depends(parse_filters(PaymentFilters)), +): + await update_pending_payments(wallet.wallet.id) + page = await get_payments_paginated( + wallet_id=wallet.wallet.id, + pending=True, + complete=True, + filters=filters, + ) + return page + + +async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet): + description_hash = b"" + unhashed_description = b"" + memo = data.memo or settings.lnbits_site_title + if data.description_hash or data.unhashed_description: + if data.description_hash: + try: + description_hash = bytes.fromhex(data.description_hash) + except ValueError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="'description_hash' must be a valid hex string", + ) + if data.unhashed_description: + try: + unhashed_description = bytes.fromhex(data.unhashed_description) + except ValueError: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="'unhashed_description' must be a valid hex string", + ) + # do not save memo if description_hash or unhashed_description is set + memo = "" + + async with db.connect() as conn: + try: + payment_hash, payment_request = await create_invoice( + wallet_id=wallet.id, + amount=data.amount, + memo=memo, + currency=data.unit, + description_hash=description_hash, + unhashed_description=unhashed_description, + expiry=data.expiry, + extra=data.extra, + webhook=data.webhook, + internal=data.internal, + conn=conn, + ) + # NOTE: we get the checking_id with a seperate query because create_invoice + # does not return it and it would be a big hustle to change its return type + # (used across extensions) + payment_db = await get_standalone_payment(payment_hash, conn=conn) + assert payment_db is not None, "payment not found" + checking_id = payment_db.checking_id + except InvoiceFailure as e: + raise HTTPException(status_code=520, detail=str(e)) + except Exception as exc: + raise exc + + invoice = bolt11.decode(payment_request) + + lnurl_response: Union[None, bool, str] = None + if data.lnurl_callback: + if data.lnurl_balance_check is not None: + await save_balance_check(wallet.id, data.lnurl_balance_check) + + headers = {"User-Agent": settings.user_agent} + async with httpx.AsyncClient(headers=headers) as client: + try: + r = await client.get( + data.lnurl_callback, + params={ + "pr": payment_request, + "balanceNotify": url_for( + f"/withdraw/notify/{urlparse(data.lnurl_callback).netloc}", + external=True, + wal=wallet.id, + ), + }, + timeout=10, + ) + if r.is_error: + lnurl_response = r.text + else: + resp = json.loads(r.text) + if resp["status"] != "OK": + lnurl_response = resp["reason"] + else: + lnurl_response = True + except (httpx.ConnectError, httpx.RequestError) as ex: + logger.error(ex) + lnurl_response = False + + return { + "payment_hash": invoice.payment_hash, + "payment_request": payment_request, + # maintain backwards compatibility with API clients: + "checking_id": checking_id, + "lnurl_response": lnurl_response, + } + + +async def api_payments_pay_invoice( + bolt11: str, wallet: Wallet, extra: Optional[dict] = None +): + try: + payment_hash = await pay_invoice( + wallet_id=wallet.id, payment_request=bolt11, extra=extra + ) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) + except PaymentFailure as e: + raise HTTPException(status_code=520, detail=str(e)) + except Exception as exc: + raise exc + + return { + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } + + +@payment_router.post( + "", + summary="Create or pay an invoice", + description=""" + This endpoint can be used both to generate and pay a BOLT11 invoice. + To generate a new invoice for receiving funds into the authorized account, + specify at least the first four fields in the POST body: `out: false`, + `amount`, `unit`, and `memo`. To pay an arbitrary invoice from the funds + already in the authorized account, specify `out: true` and use the `bolt11` + field to supply the BOLT11 invoice to be paid. + """, + status_code=HTTPStatus.CREATED, +) +async def api_payments_create( + wallet: WalletTypeInfo = Depends(require_invoice_key), + invoiceData: CreateInvoice = Body(...), +): + if invoiceData.out is True and wallet.wallet_type == WalletType.admin: + if not invoiceData.bolt11: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="BOLT11 string is invalid or not given", + ) + return await api_payments_pay_invoice( + invoiceData.bolt11, wallet.wallet, invoiceData.extra + ) # admin key + elif not invoiceData.out: + # invoice key + return await api_payments_create_invoice(invoiceData, wallet.wallet) + else: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="Invoice (or Admin) key required.", + ) + + +@payment_router.get("/fee-reserve") +async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse: + invoice_obj = bolt11.decode(invoice) + if invoice_obj.amount_msat: + response = { + "fee_reserve": fee_reserve_total(invoice_obj.amount_msat), + } + return JSONResponse(response) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invoice has no amount.", + ) + + +@payment_router.post("/lnurl") +async def api_payments_pay_lnurl( + data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key) +): + domain = urlparse(data.callback).netloc + + headers = {"User-Agent": settings.user_agent} + async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: + try: + if data.unit and data.unit != "sat": + amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit) + # no msat precision + amount_msat = ceil(amount_msat // 1000) * 1000 + else: + amount_msat = data.amount + r = await client.get( + data.callback, + params={"amount": amount_msat, "comment": data.comment}, + timeout=40, + ) + if r.is_error: + raise httpx.ConnectError("LNURL callback connection error") + r.raise_for_status() + except (httpx.ConnectError, httpx.RequestError): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to connect to {domain}.", + ) + + params = json.loads(r.text) + if params.get("status") == "ERROR": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} said: '{params.get('reason', '')}'", + ) + + if not params.get("pr"): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"{domain} did not return a payment request.", + ) + + invoice = bolt11.decode(params["pr"]) + if invoice.amount_msat != amount_msat: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + ( + f"{domain} returned an invalid invoice. Expected" + f" {amount_msat} msat, got {invoice.amount_msat}." + ), + ), + ) + + extra = {} + + if params.get("successAction"): + extra["success_action"] = params["successAction"] + if data.comment: + extra["comment"] = data.comment + if data.unit and data.unit != "sat": + extra["fiat_currency"] = data.unit + extra["fiat_amount"] = data.amount / 1000 + assert data.description is not None, "description is required" + payment_hash = await pay_invoice( + wallet_id=wallet.wallet.id, + payment_request=params["pr"], + description=data.description, + extra=extra, + ) + + return { + "success_action": params.get("successAction"), + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } + + +async def subscribe_wallet_invoices(request: Request, wallet: Wallet): + """ + Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse. + Listenes invoming payments for a wallet and yields jsons with payment details. + """ + this_wallet_id = wallet.id + + payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0) + + uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}" + logger.debug(f"adding sse listener for wallet: {uid}") + api_invoice_listeners[uid] = payment_queue + + try: + while True: + if await request.is_disconnected(): + await request.close() + break + payment: Payment = await payment_queue.get() + if payment.wallet_id == this_wallet_id: + logger.debug("sse listener: payment received", payment) + yield dict(data=payment.json(), event="payment-received") + except asyncio.CancelledError: + logger.debug(f"removing listener for wallet {uid}") + except Exception as exc: + logger.error(f"Error in sse: {exc}") + finally: + api_invoice_listeners.pop(uid) + + +@payment_router.get("/sse") +async def api_payments_sse( + request: Request, wallet: WalletTypeInfo = Depends(get_key_type) +): + return EventSourceResponse( + subscribe_wallet_invoices(request, wallet.wallet), + ping=20, + media_type="text/event-stream", + ) + + +# TODO: refactor this route into a public and admin one +@payment_router.get("/{payment_hash}") +async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): + # We use X_Api_Key here because we want this call to work with and without keys + # If a valid key is given, we also return the field "details", otherwise not + wallet = await get_wallet_for_key(X_Api_Key) if isinstance(X_Api_Key, str) else None + + payment = await get_standalone_payment( + payment_hash, wallet_id=wallet.id if wallet else None + ) + if payment is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + await check_transaction_status(payment.wallet_id, payment_hash) + payment = await get_standalone_payment( + payment_hash, wallet_id=wallet.id if wallet else None + ) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + elif not payment.pending: + if wallet and wallet.id == payment.wallet_id: + return {"paid": True, "preimage": payment.preimage, "details": payment} + return {"paid": True, "preimage": payment.preimage} + + try: + await payment.check_status() + except Exception: + if wallet and wallet.id == payment.wallet_id: + return {"paid": False, "details": payment} + return {"paid": False} + + if wallet and wallet.id == payment.wallet_id: + return { + "paid": not payment.pending, + "preimage": payment.preimage, + "details": payment, + } + return {"paid": not payment.pending, "preimage": payment.preimage} + + +@payment_router.post("/decode", status_code=HTTPStatus.OK) +async def api_payments_decode(data: DecodePayment) -> JSONResponse: + payment_str = data.data + try: + if payment_str[:5] == "LNURL": + url = str(lnurl_decode(payment_str)) + return JSONResponse({"domain": url}) + else: + invoice = bolt11.decode(payment_str) + return JSONResponse(invoice.data) + except Exception as exc: + return JSONResponse( + {"message": f"Failed to decode: {str(exc)}"}, + status_code=HTTPStatus.BAD_REQUEST, + ) diff --git a/lnbits/core/views/public_api.py b/lnbits/core/views/public_api.py index 3e265b071..cd2ae485b 100644 --- a/lnbits/core/views/public_api.py +++ b/lnbits/core/views/public_api.py @@ -9,7 +9,7 @@ from lnbits import bolt11 from ..crud import get_standalone_payment from ..tasks import api_invoice_listeners -public_router = APIRouter() +public_router = APIRouter(tags=["Core"]) @public_router.get("/public/v1/payment/{payment_hash}") diff --git a/lnbits/core/views/tinyurl_api.py b/lnbits/core/views/tinyurl_api.py index b984c59b0..6e1a3ce5a 100644 --- a/lnbits/core/views/tinyurl_api.py +++ b/lnbits/core/views/tinyurl_api.py @@ -20,7 +20,7 @@ from ..crud import ( get_tinyurl_by_url, ) -tinyurl_router = APIRouter() +tinyurl_router = APIRouter(tags=["Tinyurl"]) @tinyurl_router.post( diff --git a/lnbits/core/views/wallet_api.py b/lnbits/core/views/wallet_api.py new file mode 100644 index 000000000..94a8dadfb --- /dev/null +++ b/lnbits/core/views/wallet_api.py @@ -0,0 +1,77 @@ +from typing import Optional + +from fastapi import ( + APIRouter, + Body, + Depends, +) + +from lnbits.core.models import ( + CreateWallet, + Wallet, + WalletType, +) +from lnbits.decorators import ( + WalletTypeInfo, + get_key_type, + require_admin_key, +) + +from ..crud import ( + create_wallet, + delete_wallet, + update_wallet, +) + +wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"]) + + +@wallet_router.get("") +async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): + if wallet.wallet_type == WalletType.admin: + return { + "id": wallet.wallet.id, + "name": wallet.wallet.name, + "balance": wallet.wallet.balance_msat, + } + else: + return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat} + + +@wallet_router.put("/{new_name}") +async def api_update_wallet_name( + new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + await update_wallet(wallet.wallet.id, new_name) + return { + "id": wallet.wallet.id, + "name": wallet.wallet.name, + "balance": wallet.wallet.balance_msat, + } + + +@wallet_router.patch("", response_model=Wallet) +async def api_update_wallet( + name: Optional[str] = Body(None), + currency: Optional[str] = Body(None), + wallet: WalletTypeInfo = Depends(require_admin_key), +): + return await update_wallet(wallet.wallet.id, name, currency) + + +@wallet_router.delete("") +async def api_delete_wallet( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> None: + await delete_wallet( + user_id=wallet.wallet.user, + wallet_id=wallet.wallet.id, + ) + + +@wallet_router.post("", response_model=Wallet) +async def api_create_wallet( + data: CreateWallet, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Wallet: + return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name) diff --git a/lnbits/core/views/webpush_api.py b/lnbits/core/views/webpush_api.py index ce23f116c..7c8f0d619 100644 --- a/lnbits/core/views/webpush_api.py +++ b/lnbits/core/views/webpush_api.py @@ -24,10 +24,10 @@ from ..crud import ( get_webpush_subscription, ) -webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["webpush"]) +webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"]) -@webpush_router.post("/", status_code=HTTPStatus.CREATED) +@webpush_router.post("", status_code=HTTPStatus.CREATED) async def api_create_webpush_subscription( request: Request, data: CreateWebPushSubscription, @@ -49,7 +49,7 @@ async def api_create_webpush_subscription( ) -@webpush_router.delete("/", status_code=HTTPStatus.OK) +@webpush_router.delete("", status_code=HTTPStatus.OK) async def api_delete_webpush_subscription( request: Request, wallet: WalletTypeInfo = Depends(require_admin_key), diff --git a/lnbits/core/views/websocket_api.py b/lnbits/core/views/websocket_api.py new file mode 100644 index 000000000..577864d8d --- /dev/null +++ b/lnbits/core/views/websocket_api.py @@ -0,0 +1,40 @@ +from fastapi import ( + APIRouter, + WebSocket, + WebSocketDisconnect, +) + +from ..services import ( + websocketManager, + websocketUpdater, +) + +websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"]) + + +@websocket_router.websocket("/{item_id}") +async def websocket_connect(websocket: WebSocket, item_id: str): + await websocketManager.connect(websocket, item_id) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + websocketManager.disconnect(websocket) + + +@websocket_router.post("/{item_id}") +async def websocket_update_post(item_id: str, data: str): + try: + await websocketUpdater(item_id, data) + return {"sent": True, "data": data} + except Exception: + return {"sent": False, "data": data} + + +@websocket_router.get("/{item_id}/{data}") +async def websocket_update_get(item_id: str, data: str): + try: + await websocketUpdater(item_id, data) + return {"sent": True, "data": data} + except Exception: + return {"sent": False, "data": data} diff --git a/tests/conftest.py b/tests/conftest.py index 3987d4511..c587cbf6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,7 @@ from lnbits.core.crud import ( ) from lnbits.core.models import CreateInvoice from lnbits.core.services import update_wallet_balance -from lnbits.core.views.api import api_payments_create_invoice +from lnbits.core.views.payment_api import api_payments_create_invoice from lnbits.db import DB_TYPE, SQLITE, Database from lnbits.settings import settings from tests.helpers import ( diff --git a/tests/core/views/test_admin_api.py b/tests/core/views/test_admin_api.py index 62a3743a5..3220bf8f8 100644 --- a/tests/core/views/test_admin_api.py +++ b/tests/core/views/test_admin_api.py @@ -5,13 +5,13 @@ from lnbits.settings import settings @pytest.mark.asyncio async def test_admin_get_settings_permission_denied(client, from_user): - response = await client.get(f"/admin/api/v1/settings/?usr={from_user.id}") + response = await client.get(f"/admin/api/v1/settings?usr={from_user.id}") assert response.status_code == 401 @pytest.mark.asyncio async def test_admin_get_settings(client, superuser): - response = await client.get(f"/admin/api/v1/settings/?usr={superuser.id}") + response = await client.get(f"/admin/api/v1/settings?usr={superuser.id}") assert response.status_code == 200 result = response.json() assert "super_user" not in result @@ -21,7 +21,7 @@ async def test_admin_get_settings(client, superuser): async def test_admin_update_settings(client, superuser): new_site_title = "UPDATED SITETITLE" response = await client.put( - f"/admin/api/v1/settings/?usr={superuser.id}", + f"/admin/api/v1/settings?usr={superuser.id}", json={"lnbits_site_title": new_site_title}, ) assert response.status_code == 200 @@ -34,7 +34,7 @@ async def test_admin_update_settings(client, superuser): @pytest.mark.asyncio async def test_admin_update_noneditable_settings(client, superuser): response = await client.put( - f"/admin/api/v1/settings/?usr={superuser.id}", + f"/admin/api/v1/settings?usr={superuser.id}", json={"super_user": "UPDATED"}, ) assert response.status_code == 400 diff --git a/tests/core/views/test_api.py b/tests/core/views/test_api.py index 7eef8a99b..5cb88f6f0 100644 --- a/tests/core/views/test_api.py +++ b/tests/core/views/test_api.py @@ -8,7 +8,7 @@ from lnbits.core.crud import get_standalone_payment, update_payment_details from lnbits.core.models import CreateInvoice, Payment from lnbits.core.services import fee_reserve_total from lnbits.core.views.admin_api import api_auditor -from lnbits.core.views.api import api_payment +from lnbits.core.views.payment_api import api_payment from lnbits.settings import settings from lnbits.wallets import get_wallet_class