diff --git a/integration/fragments/init-server.jmx b/integration/fragments/init-server.jmx
index e7762c18..d80e34da 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 7800afc2..e67a2193 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 b43a6046..0b7d6edf 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 40ae228a..1ce6a0f6 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 dffd3123..d9ad8d0d 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 dc157c8e..f865ae89 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 5321ed63..366d6918 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 00000000..db8c0495
--- /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 c12bab74..917c2493 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 00000000..641f7213
--- /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 3e265b07..cd2ae485 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 b984c59b..6e1a3ce5 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 00000000..94a8dadf
--- /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 ce23f116..7c8f0d61 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 00000000..577864d8
--- /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 3987d451..c587cbf6 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 62a3743a..3220bf8f 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 7eef8a99..5cb88f6f 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