diff --git a/.env.example b/.env.example index 898f90bd..bb4e64a1 100644 --- a/.env.example +++ b/.env.example @@ -10,13 +10,16 @@ DEBUG=false LNBITS_ALLOWED_USERS="" LNBITS_ADMIN_USERS="" # Extensions only admin can access -LNBITS_ADMIN_EXTENSIONS="ngrok" +LNBITS_ADMIN_EXTENSIONS="ngrok, admin" +# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available +LNBITS_ADMIN_UI=false + LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" # Ad space description # LNBITS_AD_SPACE_TITLE="Supported by" # csv ad space, format ";;, ;;", extensions can choose to honor -# LNBITS_AD_SPACE="" +# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png" # Hides wallet api, extensions can choose to honor LNBITS_HIDE_API=false @@ -105,6 +108,6 @@ LNTIPS_API_KEY=LNTIPS_ADMIN_KEY LNTIPS_API_ENDPOINT=https://ln.tips # Cashu Mint -# Use a long-enough random (!) private key. +# Use a long-enough random (!) private key. # Once set, you cannot change this key as for now. CASHU_PRIVATE_KEY="SuperSecretPrivateKey" diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index f6fa53e9..a9f58985 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -7,6 +7,7 @@ on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+.[0-9]+" - "[0-9]+.[0-9]+.[0-9]+-*" jobs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5d368fbb..487411ed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - env: + env: VIRTUAL_ENV: ./venv PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | @@ -43,9 +43,6 @@ jobs: with: poetry-version: ${{ matrix.poetry-version }} - name: Install dependencies - env: - VIRTUAL_ENV: ./venv - PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }} run: | poetry install - name: Run tests diff --git a/Dockerfile b/Dockerfile index f107f68c..cc3a14bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM python:3.10-slim RUN apt-get clean RUN apt-get update @@ -13,7 +13,7 @@ RUN mkdir -p lnbits/data COPY . . RUN poetry config virtualenvs.create false -RUN poetry install --no-dev --no-root +RUN poetry install --only main --no-root RUN poetry run python build.py ENV LNBITS_PORT="5000" diff --git a/Makefile b/Makefile index 4f99f1da..ebf2a872 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ format: prettier isort black check: mypy checkprettier checkisort checkblack -prettier: $(shell find lnbits -name "*.js" -name ".html") +prettier: $(shell find lnbits -name "*.js" -o -name ".html") ./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html black: @@ -18,7 +18,7 @@ mypy: isort: poetry run isort . -checkprettier: $(shell find lnbits -name "*.js" -name ".html") +checkprettier: $(shell find lnbits -name "*.js" -o -name ".html") ./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html checkblack: diff --git a/docs/_config.yml b/docs/_config.yml index 74e65187..6c3d6512 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,7 +1,7 @@ title: "LNbits docs" - remote_theme: pmarsceill/just-the-docs -logo: "/logos/lnbits-full.png" +color_scheme: dark +logo: "/logos/lnbits-full--inverse.png" search_enabled: true url: https://legend.lnbits.org aux_links: diff --git a/docs/guide/admin_ui.md b/docs/guide/admin_ui.md new file mode 100644 index 00000000..9637a989 --- /dev/null +++ b/docs/guide/admin_ui.md @@ -0,0 +1,72 @@ +--- +layout: default +title: Admin UI +nav_order: 4 +--- + + +Admin UI +======== +The LNbits Admin UI lets you change LNbits settings via the LNbits frontend. +It is disabled by default and the first time you set the enviroment variable LNBITS_ADMIN_UI=true +the settings are initialized and saved to the database and will be used from there as long the UI is enabled. +From there on the settings from the database are used. + + +Super User +========== +With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs. +The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources. + +Also only the super user can brrrr satoshis to different wallets. + +The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened, +a new super user is created. + +The super user is never sent over the api and the frontend only receives a bool if you are super user or not. + +We also added a decorator for the API routes to check for super user. + +There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings` + + +Admin Users +=========== +enviroment variable: LNBITS_ADMIN_USERS, comma-seperated list of user ids +Admin Users can change settings in the admin ui aswell, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessable. Also they have access to all the extension defined in LNBITS_ADMIN_EXTENSIONS. + + +Allowed Users +============= +enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids +By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend. + + +How to activate +============= +``` +$ sudo systemctl stop lnbits.service +$ cd ~/lnbits-legend +$ sudo nano .env +``` +-> set: `LNBITS_ADMIN_UI=true` + +Now start LNbits once in the terminal window +``` +$ poetry run lnbits +``` +It will now show you the Super User Account: + +`SUCCESS | ✔️ Access super user account at: https://127.0.0.1:5000/wallet?usr=5711d7..` + +The `/wallet?usr=..` is your super user account. You just have to append it to your normal LNbits web domain. + +After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions` + +Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee. + +Do not forget +``` +sudo systemctl start lnbits.service +``` +A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid. diff --git a/flake.nix b/flake.nix index af25ba5c..d9f0f1f0 100644 --- a/flake.nix +++ b/flake.nix @@ -5,7 +5,7 @@ }; outputs = { self, nixpkgs, poetry2nix }@inputs: let - supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forSystems = systems: f: nixpkgs.lib.genAttrs systems (system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; })); diff --git a/lnbits/__main__.py b/lnbits/__main__.py index 90cb1997..793f52dc 100644 --- a/lnbits/__main__.py +++ b/lnbits/__main__.py @@ -1,38 +1,3 @@ -import asyncio - -import uvloop -from loguru import logger -from starlette.requests import Request - -from .commands import migrate_databases -from .settings import ( - DEBUG, - HOST, - LNBITS_COMMIT, - LNBITS_DATA_FOLDER, - LNBITS_DATABASE_URL, - LNBITS_SITE_TITLE, - PORT, - WALLET, -) - -uvloop.install() - -asyncio.create_task(migrate_databases()) - from .app import create_app app = create_app() - -logger.info("Starting LNbits") -logger.info(f"Host: {HOST}") -logger.info(f"Port: {PORT}") -logger.info(f"Debug: {DEBUG}") -logger.info(f"Site title: {LNBITS_SITE_TITLE}") -logger.info(f"Funding source: {WALLET.__class__.__name__}") -logger.info( - f"Database: {'PostgreSQL' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('postgres://') else 'CockroachDB' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('cockroachdb://') else 'SQLite'}" -) -logger.info(f"Data folder: {LNBITS_DATA_FOLDER}") -logger.info(f"Git version: {LNBITS_COMMIT}") -# logger.info(f"Service fee: {SERVICE_FEE}") diff --git a/lnbits/app.py b/lnbits/app.py index 075828ef..1b1292ce 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -4,21 +4,22 @@ import logging import signal import sys import traceback -import warnings from http import HTTPStatus from fastapi import FastAPI, Request -from fastapi.exceptions import RequestValidationError +from fastapi.exceptions import HTTPException, RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from loguru import logger -import lnbits.settings from lnbits.core.tasks import register_task_listeners +from lnbits.settings import get_wallet_class, set_wallet_class, settings +from .commands import migrate_databases from .core import core_app +from .core.services import check_admin_settings from .core.views.generic import core_html_routes from .helpers import ( get_css_vendored, @@ -28,7 +29,6 @@ from .helpers import ( url_for_vendored, ) from .requestvars import g -from .settings import WALLET from .tasks import ( catch_everything_and_restart, check_pending_payments, @@ -38,10 +38,8 @@ from .tasks import ( ) -def create_app(config_object="lnbits.settings") -> FastAPI: - """Create application factory. - :param config_object: The configuration object to use. - """ +def create_app() -> FastAPI: + configure_logger() app = FastAPI( @@ -49,9 +47,10 @@ def create_app(config_object="lnbits.settings") -> FastAPI: description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.", license_info={ "name": "MIT License", - "url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE", + "url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE", }, ) + app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static") app.mount( "/core/static", @@ -59,40 +58,15 @@ def create_app(config_object="lnbits.settings") -> FastAPI: name="core_static", ) - origins = ["*"] + g().base_url = f"http://{settings.host}:{settings.port}" app.add_middleware( - CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"] + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] ) - g().config = lnbits.settings - g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}" - - @app.exception_handler(RequestValidationError) - async def validation_exception_handler( - request: Request, exc: RequestValidationError - ): - # Only the browser sends "text/html" request - # not fail proof, but everything else get's a JSON response - - if ( - request.headers - and "accept" in request.headers - and "text/html" in request.headers["accept"] - ): - return template_renderer().TemplateResponse( - "error.html", - {"request": request, "err": f"{exc.errors()} is not a valid UUID."}, - ) - - return JSONResponse( - status_code=HTTPStatus.NO_CONTENT, - content={"detail": exc.errors()}, - ) - app.add_middleware(GZipMiddleware, minimum_size=1000) - check_funding_source(app) + register_startup(app) register_assets(app) register_routes(app) register_async_tasks(app) @@ -101,33 +75,34 @@ def create_app(config_object="lnbits.settings") -> FastAPI: return app -def check_funding_source(app: FastAPI) -> None: - @app.on_event("startup") - async def check_wallet_status(): - original_sigint_handler = signal.getsignal(signal.SIGINT) +async def check_funding_source() -> None: - def signal_handler(signal, frame): - logger.debug(f"SIGINT received, terminating LNbits.") - sys.exit(1) + original_sigint_handler = signal.getsignal(signal.SIGINT) - signal.signal(signal.SIGINT, signal_handler) - while True: - try: - error_message, balance = await WALLET.status() - if not error_message: - break - logger.error( - f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", - RuntimeWarning, - ) - except: - pass - logger.info("Retrying connection to backend in 5 seconds...") - await asyncio.sleep(5) - signal.signal(signal.SIGINT, original_sigint_handler) - logger.success( - f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." - ) + def signal_handler(signal, frame): + logger.debug(f"SIGINT received, terminating LNbits.") + sys.exit(1) + + signal.signal(signal.SIGINT, signal_handler) + + WALLET = get_wallet_class() + while True: + try: + error_message, balance = await WALLET.status() + if not error_message: + break + logger.error( + f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", + RuntimeWarning, + ) + except: + pass + logger.info("Retrying connection to backend in 5 seconds...") + await asyncio.sleep(5) + signal.signal(signal.SIGINT, original_sigint_handler) + logger.info( + f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." + ) def register_routes(app: FastAPI) -> None: @@ -158,12 +133,59 @@ def register_routes(app: FastAPI) -> None: ) +def register_startup(app: FastAPI): + @app.on_event("startup") + async def lnbits_startup(): + + try: + # 1. wait till migration is done + await migrate_databases() + + # 2. setup admin settings + await check_admin_settings() + + log_server_info() + + # 3. initialize WALLET + set_wallet_class() + + # 4. initialize funding source + await check_funding_source() + except Exception as e: + logger.error(str(e)) + raise ImportError("Failed to run 'startup' event.") + + +def log_server_info(): + logger.info("Starting LNbits") + logger.info(f"Host: {settings.host}") + logger.info(f"Port: {settings.port}") + logger.info(f"Debug: {settings.debug}") + logger.info(f"Site title: {settings.lnbits_site_title}") + logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}") + logger.info(f"Data folder: {settings.lnbits_data_folder}") + logger.info(f"Git version: {settings.lnbits_commit}") + logger.info(f"Database: {get_db_vendor_name()}") + logger.info(f"Service fee: {settings.lnbits_service_fee}") + + +def get_db_vendor_name(): + db_url = settings.lnbits_database_url + return ( + "PostgreSQL" + if db_url and db_url.startswith("postgres://") + else "CockroachDB" + if db_url and db_url.startswith("cockroachdb://") + else "SQLite" + ) + + def register_assets(app: FastAPI): """Serve each vendored asset separately or a bundle.""" @app.on_event("startup") async def vendored_assets_variable(): - if g().config.DEBUG: + if settings.debug: g().VENDORED_JS = map(url_for_vendored, get_js_vendored()) g().VENDORED_CSS = map(url_for_vendored, get_css_vendored()) else: @@ -192,12 +214,33 @@ def register_async_tasks(app): def register_exception_handlers(app: FastAPI): @app.exception_handler(Exception) - async def basic_error(request: Request, err): - logger.error("handled error", traceback.format_exc()) - logger.error("ERROR:", err) + async def exception_handler(request: Request, exc: Exception): etype, _, tb = sys.exc_info() - traceback.print_exception(etype, err, tb) - exc = traceback.format_exc() + traceback.print_exception(etype, exc, tb) + logger.error(f"Exception: {str(exc)}") + # Only the browser sends "text/html" request + # not fail proof, but everything else get's a JSON response + if ( + request.headers + and "accept" in request.headers + and "text/html" in request.headers["accept"] + ): + return template_renderer().TemplateResponse( + "error.html", {"request": request, "err": f"Error: {str(exc)}"} + ) + + return JSONResponse( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + content={"detail": str(exc)}, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler( + request: Request, exc: RequestValidationError + ): + logger.error(f"RequestValidationError: {str(exc)}") + # Only the browser sends "text/html" request + # not fail proof, but everything else get's a JSON response if ( request.headers @@ -205,18 +248,43 @@ def register_exception_handlers(app: FastAPI): and "text/html" in request.headers["accept"] ): return template_renderer().TemplateResponse( - "error.html", {"request": request, "err": err} + "error.html", + {"request": request, "err": f"Error: {str(exc)}"}, ) return JSONResponse( - status_code=HTTPStatus.NO_CONTENT, - content={"detail": err}, + status_code=HTTPStatus.BAD_REQUEST, + content={"detail": str(exc)}, + ) + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + logger.error(f"HTTPException {exc.status_code}: {exc.detail}") + # Only the browser sends "text/html" request + # not fail proof, but everything else get's a JSON response + + if ( + request.headers + and "accept" in request.headers + and "text/html" in request.headers["accept"] + ): + return template_renderer().TemplateResponse( + "error.html", + { + "request": request, + "err": f"HTTP Error {exc.status_code}: {exc.detail}", + }, + ) + + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, ) def configure_logger() -> None: logger.remove() - log_level: str = "DEBUG" if lnbits.settings.DEBUG else "INFO" + log_level: str = "DEBUG" if settings.debug else "INFO" formatter = Formatter() logger.add(sys.stderr, level=log_level, format=formatter.format) @@ -228,7 +296,7 @@ class Formatter: def __init__(self): self.padding = 0 self.minimal_fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level} | {message}\n" - if lnbits.settings.DEBUG: + if settings.debug: self.fmt: str = "{time:YYYY-MM-DD HH:mm:ss.SS} | {level: <4} | {name}:{function}:{line} | {message}\n" else: self.fmt: str = self.minimal_fmt diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 32b43feb..41b73b7d 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -1,7 +1,6 @@ import hashlib import re import time -from binascii import unhexlify from decimal import Decimal from typing import List, NamedTuple, Optional @@ -108,7 +107,7 @@ def decode(pr: str) -> Invoice: message = bytearray([ord(c) for c in hrp]) + data.tobytes() sig = signature[0:64] if invoice.payee: - key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1) + key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1) key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string) else: keys = VerifyingKey.from_public_key_recovery( @@ -131,7 +130,7 @@ def encode(options): if options["timestamp"]: addr.date = int(options["timestamp"]) - addr.paymenthash = unhexlify(options["paymenthash"]) + addr.paymenthash = bytes.fromhex(options["paymenthash"]) if options["description"]: addr.tags.append(("d", options["description"])) @@ -149,8 +148,8 @@ def encode(options): while len(splits) >= 5: route.append( ( - unhexlify(splits[0]), - unhexlify(splits[1]), + bytes.fromhex(splits[0]), + bytes.fromhex(splits[1]), int(splits[2]), int(splits[3]), int(splits[4]), @@ -235,7 +234,7 @@ def lnencode(addr, privkey): raise ValueError("Must include either 'd' or 'h'") # We actually sign the hrp, then data (padded to 8 bits with zeroes). - privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey))) + privkey = secp256k1.PrivateKey(bytes.fromhex(privkey)) sig = privkey.ecdsa_sign_recoverable( bytearray([ord(c) for c in hrp]) + data.tobytes() ) @@ -261,7 +260,7 @@ class LnAddr(object): def __str__(self): return "LnAddr[{}, amount={}{} tags=[{}]]".format( - hexlify(self.pubkey.serialize()).decode("utf-8"), + bytes.hex(self.pubkey.serialize()).decode("utf-8"), self.amount, self.currency, ", ".join([k + "=" + str(v) for k, v in self.tags]), diff --git a/lnbits/commands.py b/lnbits/commands.py index a519405a..82ea1430 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -7,6 +7,8 @@ import warnings import click from loguru import logger +from lnbits.settings import settings + from .core import db as core_db from .core import migrations as core_migrations from .db import COCKROACH, POSTGRES, SQLITE @@ -16,7 +18,6 @@ from .helpers import ( get_valid_extensions, url_for_vendored, ) -from .settings import LNBITS_PATH @click.command("migrate") @@ -35,15 +36,17 @@ def transpile_scss(): warnings.simplefilter("ignore") from scss.compiler import compile_string # type: ignore - with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss: - with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css: + with open(os.path.join(settings.lnbits_path, "static/scss/base.scss")) as scss: + with open( + os.path.join(settings.lnbits_path, "static/css/base.css"), "w" + ) as css: css.write(compile_string(scss.read())) def bundle_vendored(): for getfiles, outputpath in [ - (get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")), - (get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")), + (get_js_vendored, os.path.join(settings.lnbits_path, "static/bundle.js")), + (get_css_vendored, os.path.join(settings.lnbits_path, "static/bundle.css")), ]: output = "" for path in getfiles(): diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index 85e72d50..dec15d78 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -6,6 +6,7 @@ db = Database("database") core_app: APIRouter = APIRouter() +from .views.admin_api import * # noqa from .views.api import * # noqa from .views.generic import * # noqa from .views.public_api import * # noqa diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 2baa0507..a80fadf2 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -4,11 +4,9 @@ from typing import Any, Dict, List, Optional from urllib.parse import urlparse from uuid import uuid4 -from loguru import logger - from lnbits import bolt11 from lnbits.db import COCKROACH, POSTGRES, Connection -from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS +from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings from . import db from .models import BalanceCheck, Payment, User, Wallet @@ -63,9 +61,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[ email=user["email"], extensions=[e[0] for e in extensions], wallets=[Wallet(**w) for w in wallets], - admin=user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS] - if LNBITS_ADMIN_USERS - else False, + admin=user["id"] == settings.super_user + or user["id"] in settings.lnbits_admin_users, ) @@ -99,7 +96,7 @@ async def create_wallet( """, ( wallet_id, - wallet_name or DEFAULT_WALLET_NAME, + wallet_name or settings.lnbits_default_wallet_name, user_id, uuid4().hex, uuid4().hex, @@ -232,8 +229,8 @@ async def get_wallet_payment( async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: int = 5): rows = await db.fetchall( f""" - SELECT * FROM apipayments - WHERE pending = 'false' + SELECT * FROM apipayments + WHERE pending = 'false' AND extra LIKE ? AND extra LIKE ? ORDER BY time DESC LIMIT {limit} @@ -454,6 +451,34 @@ async def update_payment_details( return +async def update_payment_extra( + payment_hash: str, + extra: dict, + outgoing: bool = False, + conn: Optional[Connection] = None, +) -> None: + """ + Only update the `extra` field for the payment. + Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them. + """ + + amount_clause = "AND amount < 0" if outgoing else "AND amount > 0" + + row = await (conn or db).fetchone( + f"SELECT hash, extra from apipayments WHERE hash = ? {amount_clause}", + (payment_hash,), + ) + if not row: + return + db_extra = json.loads(row["extra"] if row["extra"] else "{}") + db_extra.update(extra) + + await (conn or db).execute( + f"UPDATE apipayments SET extra = ? WHERE hash = ? {amount_clause} ", + (json.dumps(db_extra), payment_hash), + ) + + async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None: await (conn or db).execute( "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) @@ -550,3 +575,48 @@ async def get_balance_notify( (wallet_id,), ) return row[0] if row else None + + +# admin +# -------- + + +async def get_super_settings() -> Optional[SuperSettings]: + row = await db.fetchone("SELECT * FROM settings") + if not row: + return None + editable_settings = json.loads(row["editable_settings"]) + return SuperSettings(**{"super_user": row["super_user"], **editable_settings}) + + +async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]: + sets = await get_super_settings() + if not sets: + return None + row_dict = dict(sets) + row_dict.pop("super_user") + admin_settings = AdminSettings( + super_user=is_super_user, + lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources, + **row_dict, + ) + return admin_settings + + +async def delete_admin_settings(): + await db.execute("DELETE FROM settings") + + +async def update_admin_settings(data: EditableSettings): + await db.execute(f"UPDATE settings SET editable_settings = ?", (json.dumps(data),)) + + +async def update_super_user(super_user: str): + await db.execute("UPDATE settings SET super_user = ?", (super_user,)) + return await get_super_settings() + + +async def create_admin_settings(super_user: str, new_settings: dict): + sql = f"INSERT INTO settings (super_user, editable_settings) VALUES (?, ?)" + await db.execute(sql, (super_user, json.dumps(new_settings))) + return await get_super_settings() diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index 2bffa5c7..41ba5644 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -224,7 +224,7 @@ async def m007_set_invoice_expiries(db): ) ).fetchall() if len(rows): - logger.info(f"Mirgraion: Checking expiry of {len(rows)} invoices") + logger.info(f"Migration: Checking expiry of {len(rows)} invoices") for i, ( payment_request, checking_id, @@ -238,7 +238,7 @@ async def m007_set_invoice_expiries(db): invoice.date + invoice.expiry ) logger.info( - f"Mirgraion: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}" + f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}" ) await db.execute( """ @@ -258,3 +258,14 @@ async def m007_set_invoice_expiries(db): # catching errors like this won't be necessary in anymore now that we # keep track of db versions so no migration ever runs twice. pass + + +async def m008_create_admin_settings_table(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS settings ( + super_user TEXT, + editable_settings TEXT NOT NULL DEFAULT '{}' + ); + """ + ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 62f8aa39..138a39f7 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -4,16 +4,17 @@ import hmac import json import time from sqlite3 import Row -from typing import Dict, List, NamedTuple, Optional +from typing import Dict, List, Optional from ecdsa import SECP256k1, SigningKey # type: ignore +from fastapi import Query from lnurl import encode as lnurl_encode # type: ignore from loguru import logger from pydantic import BaseModel from lnbits.db import Connection from lnbits.helpers import url_for -from lnbits.settings import WALLET +from lnbits.settings import get_wallet_class from lnbits.wallets.base import PaymentStatus @@ -65,6 +66,7 @@ class User(BaseModel): wallets: List[Wallet] = [] password: Optional[str] = None admin: bool = False + super_user: bool = False @property def wallet_ids(self) -> List[str]: @@ -171,6 +173,7 @@ class Payment(BaseModel): f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}" ) + WALLET = get_wallet_class() if self.is_out: status = await WALLET.get_payment_status(self.checking_id) else: diff --git a/lnbits/core/services.py b/lnbits/core/services.py index beb0f97a..8dc973e7 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -1,38 +1,45 @@ import asyncio import json -from binascii import unhexlify from io import BytesIO from typing import Dict, List, Optional, Tuple from urllib.parse import parse_qs, urlparse import httpx -from fastapi import Depends, WebSocket, WebSocketDisconnect +from fastapi import Depends, WebSocket from lnurl import LnurlErrorResponse from lnurl import decode as decode_lnurl # type: ignore from loguru import logger from lnbits import bolt11 from lnbits.db import Connection -from lnbits.decorators import ( - WalletTypeInfo, - get_key_type, - require_admin_key, - require_invoice_key, -) +from lnbits.decorators import WalletTypeInfo, require_admin_key from lnbits.helpers import url_for, urlsafe_short_hash from lnbits.requestvars import g -from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET +from lnbits.settings import ( + FAKE_WALLET, + EditableSettings, + get_wallet_class, + readonly_variables, + send_admin_user_to_saas, + settings, +) from lnbits.wallets.base import PaymentResponse, PaymentStatus from . import db from .crud import ( check_internal, + create_account, + create_admin_settings, create_payment, + create_wallet, delete_wallet_payment, + get_account, + get_super_settings, get_wallet, get_wallet_payment, update_payment_details, update_payment_status, + update_super_user, ) from .models import Payment @@ -65,7 +72,7 @@ async def create_invoice( invoice_memo = None if description_hash else memo # use the fake wallet if the invoice is for internal use only - wallet = FAKE_WALLET if internal else WALLET + wallet = FAKE_WALLET if internal else get_wallet_class() ok, checking_id, payment_request, error_message = await wallet.create_invoice( amount=amount, @@ -193,6 +200,7 @@ async def pay_invoice( else: logger.debug(f"backend: sending payment {temp_id}") # actually pay the external invoice + WALLET = get_wallet_class() payment: PaymentResponse = await WALLET.pay_invoice( payment_request, fee_reserve_msat ) @@ -294,7 +302,7 @@ async def perform_lnurlauth( ) -> Optional[LnurlErrorResponse]: cb = urlparse(callback) - k1 = unhexlify(parse_qs(cb.query)["k1"][0]) + k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0]) key = wallet.wallet.lnurlauth_key(cb.netloc) @@ -381,7 +389,88 @@ async def check_transaction_status( # WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ def fee_reserve(amount_msat: int) -> int: - return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0)) + reserve_min = settings.lnbits_reserve_fee_min + reserve_percent = settings.lnbits_reserve_fee_percent + return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0)) + + +async def update_wallet_balance(wallet_id: str, amount: int): + internal_id = f"internal_{urlsafe_short_hash()}" + payment = await create_payment( + wallet_id=wallet_id, + checking_id=internal_id, + payment_request="admin_internal", + payment_hash="admin_internal", + amount=amount * 1000, + memo="Admin top up", + pending=False, + ) + # manually send this for now + from lnbits.tasks import internal_invoice_queue + + await internal_invoice_queue.put(internal_id) + return payment + + +async def check_admin_settings(): + if settings.lnbits_admin_ui: + settings_db = await get_super_settings() + if not settings_db: + # create new settings if table is empty + logger.warning("Settings DB empty. Inserting default settings.") + settings_db = await init_admin_settings(settings.super_user) + logger.warning("Initialized settings from enviroment variables.") + + if settings.super_user and settings.super_user != settings_db.super_user: + # .env super_user overwrites DB super_user + settings_db = await update_super_user(settings.super_user) + + update_cached_settings(settings_db.dict()) + + # printing settings for debugging + logger.debug(f"Admin settings:") + for key, value in settings.dict(exclude_none=True).items(): + logger.debug(f"{key}: {value}") + + http = "https" if settings.lnbits_force_https else "http" + admin_url = ( + f"{http}://{settings.host}:{settings.port}/wallet?usr={settings.super_user}" + ) + logger.success(f"✔️ Access super user account at: {admin_url}") + + # callback for saas + if ( + settings.lnbits_saas_callback + and settings.lnbits_saas_secret + and settings.lnbits_saas_instance_id + ): + send_admin_user_to_saas() + + +def update_cached_settings(sets_dict: dict): + for key, value in sets_dict.items(): + if not key in readonly_variables: + try: + setattr(settings, key, value) + except: + logger.error(f"error overriding setting: {key}, value: {value}") + if "super_user" in sets_dict: + setattr(settings, "super_user", sets_dict["super_user"]) + + +async def init_admin_settings(super_user: str = None): + account = None + if super_user: + account = await get_account(super_user) + if not account: + account = await create_account() + super_user = account.id + if not account.wallets or len(account.wallets) == 0: + await create_wallet(user_id=account.id) + + editable_settings = EditableSettings.from_dict(settings.dict()) + + return await create_admin_settings(account.id, editable_settings.dict()) class WebsocketConnectionManager: diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 66801313..da3cd935 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -259,25 +259,30 @@ new Vue({ this.parse.camera.show = false }, updateBalance: function (credit) { - if (LNBITS_DENOMINATION != 'sats') { - credit = credit * 100 - } LNbits.api - .request('PUT', '/api/v1/wallet/balance/' + credit, this.g.wallet.inkey) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - .then(response => { - let data = response.data - if (data.status === 'ERROR') { - this.$q.notify({ - timeout: 5000, - type: 'warning', - message: `Failed to update.` - }) - return + .request( + 'PUT', + '/admin/api/v1/topup/?usr=' + this.g.user.id, + this.g.user.wallets[0].adminkey, + { + amount: credit, + id: this.g.user.wallets[0].id } - this.balance = this.balance + data.balance + ) + .then(response => { + this.$q.notify({ + type: 'positive', + message: + 'Success! Added ' + + credit + + ' sats to ' + + this.g.user.wallets[0].id, + icon: null + }) + this.balance += parseInt(credit) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) }) }, closeReceiveDialog: function () { diff --git a/lnbits/core/tasks.py b/lnbits/core/tasks.py index b57e2625..e11f764b 100644 --- a/lnbits/core/tasks.py +++ b/lnbits/core/tasks.py @@ -4,7 +4,6 @@ from typing import Dict import httpx from loguru import logger -from lnbits.helpers import get_current_extension_name from lnbits.tasks import SseListenersDict, register_invoice_listener from . import db diff --git a/lnbits/core/templates/admin/_tab_funding.html b/lnbits/core/templates/admin/_tab_funding.html new file mode 100644 index 00000000..3887e151 --- /dev/null +++ b/lnbits/core/templates/admin/_tab_funding.html @@ -0,0 +1,95 @@ + + +
Wallets Management
+
+
+
+
+

Funding Source Info

+
    + {%raw%} +
  • Funding Source: {{settings.lnbits_backend_wallet_class}}
  • +
  • Balance: {{balance / 1000}} sats
  • + {%endraw%} +
+
+
+
+
+
+
+
+

Active Funding (Requires server restart)

+ +
+
+
+
+
+

Fee reserve

+
+
+ + +
+
+ +
+
+
+
+
+
+

+ Funding Sources (Requires server restart) +

+ + + + + + + + + +
+
+
+
diff --git a/lnbits/core/templates/admin/_tab_server.html b/lnbits/core/templates/admin/_tab_server.html new file mode 100644 index 00000000..f234f182 --- /dev/null +++ b/lnbits/core/templates/admin/_tab_server.html @@ -0,0 +1,74 @@ + + +
Server Management
+
+
+
+
+

Server Info

+
    + {%raw%} +
  • + SQlite: {{settings.lnbits_data_folder}} +
  • +
  • + Postgres: {{settings.lnbits_database_url}} +
  • + {%endraw%} +
+
+
+
+
+
+

Service Fee

+ +
+
+
+

Miscelaneous

+ + + Force HTTPS + Prefer secure URLs + + + + + + + + Hide API + Hides wallet api, extensions can choose to honor + + + + + +
+
+
+
+
+
diff --git a/lnbits/core/templates/admin/_tab_theme.html b/lnbits/core/templates/admin/_tab_theme.html new file mode 100644 index 00000000..8a74cc5a --- /dev/null +++ b/lnbits/core/templates/admin/_tab_theme.html @@ -0,0 +1,117 @@ + + +
UI Management
+
+
+
+
+

Site Title

+ +
+
+
+

Site Tagline

+ +
+
+
+
+

Site Description

+ +
+
+
+
+

Default Wallet Name

+ +
+
+
+

Denomination

+ +
+
+
+
+
+

Themes

+ +
+
+
+

Custom Logo

+ +
+
+
+
+
+

Ad Space Title

+ +
+
+
+

Advertisement Slots

+ + + +
+
+
+
+
+
diff --git a/lnbits/core/templates/admin/_tab_users.html b/lnbits/core/templates/admin/_tab_users.html new file mode 100644 index 00000000..c6a4b83e --- /dev/null +++ b/lnbits/core/templates/admin/_tab_users.html @@ -0,0 +1,88 @@ + + +
User Management
+
+
+

Admin Users

+ + + +
+ {%raw%} + + {{ user }} + + {%endraw%} +
+
+
+
+

Allowed Users

+ + + +
+ {% raw %} + + {{ user }} + + {% endraw %} +
+
+
+
+
+

Admin Extensions

+ +
+
+
+

Disabled Extensions

+ +
+
+
+
+
diff --git a/lnbits/core/templates/admin/index.html b/lnbits/core/templates/admin/index.html new file mode 100644 index 00000000..81357101 --- /dev/null +++ b/lnbits/core/templates/admin/index.html @@ -0,0 +1,529 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + Save your changes + + + + + Restart the server for changes to take effect + + + + + Add funds to a wallet. + + + + Delete all settings and reset to defaults. + +
+
+
+
+ +
+
+ + + + + + +
+
+ + + {% include "admin/_tab_funding.html" %} {% include + "admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {% + include "admin/_tab_theme.html" %} + + +
+
+
+ + + +

TopUp a wallet

+
+
+ +
+
+
+ +
+
+
+ + Cancel +
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/core/templates/core/index.html b/lnbits/core/templates/core/index.html index 5f26cb03..a28030c0 100644 --- a/lnbits/core/templates/core/index.html +++ b/lnbits/core/templates/core/index.html @@ -82,7 +82,7 @@ > -

{{SITE_DESCRIPTION}}

+

{{SITE_DESCRIPTION | safe}}

diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 22fbd05d..9de96956 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -10,739 +10,791 @@ {% block page %}
-
- - -

- {% raw %}{{ formattedBalance }} {% endraw %} - {{LNBITS_DENOMINATION}} - - - + {% elif HIDE_API %} +
+ {% else %} +
+ {% endif %} + + +

+ {% raw %}{{ formattedBalance }} {% endraw %} + {{LNBITS_DENOMINATION}} + - - - + + + + + + + + +

+
+
+
+ Paste Request - - - - -

-
-
-
- Paste Request -
-
- Create Invoice -
-
- scan - Use camera to scan an invoice/QR - -
-
-
- - - -
-
-
Transactions
+
+
+ Create Invoice +
+
+ scan + Use camera to scan an invoice/QR + +
-
- Export to CSV - - + Show chart + +
+
+ + + - Show chart - -
- - - - - {% raw %} - - + + {% endraw %} + + + + + + {% if HIDE_API %} +
+ {% else %} +
+ + +
+ {{ SITE_TITLE }} Wallet: + {{ wallet.name }} +
+
+ + + + + {% include "core/_api_docs.html" %} + + + {% if wallet.lnurlwithdraw_full %} + + + +

+ This is an LNURL-withdraw QR code for slurping + everything from this wallet. Do not share with anyone. +

+ + + +

+ It is compatible with balanceCheck and + balanceNotify so your wallet may keep + pulling the funds continuously from here after the first + withdraw. +

+
+
+
+ + {% endif %} + + + + +

+ This QR code contains your wallet URL with full access. + You can scan it from your phone to open your wallet from + there. +

+ +
+
+
+ + + + +
+ +
Copy invoiceUpdate name - Close +
+
+ + + + +

+ This whole wallet will be deleted, the funds will be + UNRECOVERABLE. +

+ Delete wallet -
-
-
- - Payment Received - -
-
- - Payment Sent - -
-
- - Outgoing payment pending - -
- - - - - {% endraw %} - - - - + + + + + + + {% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = + ADS.split(";") %} + + +
+ {{ AD_SPACE_TITLE }} +
+
+ + + + +
{% endfor %} {% endif %} + + - {% if HIDE_API %} -
- {% else %} -
- - -
- {{ SITE_TITLE }} Wallet: {{ wallet.name }} -
-
- - - - - {% include "core/_api_docs.html" %} - - - {% if wallet.lnurlwithdraw_full %} - - - -

- This is an LNURL-withdraw QR code for slurping everything - from this wallet. Do not share with anyone. -

- - - -

- It is compatible with balanceCheck and - balanceNotify so your wallet may keep pulling - the funds continuously from here after the first withdraw. -

-
-
-
- + + {% raw %} + + +

+ {{receive.lnurl.domain}} is requesting an invoice: +

+ {% endraw %} {% if LNBITS_DENOMINATION != 'sats' %} + + {% else %} + + {% endif %} - - - -

- This QR code contains your wallet URL with full access. You - can scan it from your phone to open your wallet from there. -

- -
-
-
- - - - -
- -
- Update name -
-
-
- - - - -

- This whole wallet will be deleted, the funds will be - UNRECOVERABLE. -

- Delete wallet -
-
-
-
-
-
- {% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = - ADS.split(';') %} - - -
{{ AD_TITLE }}
-
- - - - -
{% endfor %} {% endif %} -
-
- - - {% raw %} - - -

- {{receive.lnurl.domain}} is requesting an invoice: -

- {% endraw %} {% if LNBITS_DENOMINATION != 'sats' %} - - {% else %} - - - {% endif %} - - - {% raw %} -
- - - Withdraw from {{receive.lnurl.domain}} - - Create invoice - - Cancel -
- -
-
- - -
- Copy invoice - Close -
-
- {% endraw %} -
- - - -
-
- {% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", - "")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} -
-
- {{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %} -
- -

- Description: {{ parse.invoice.description }}
- Expire date: {{ parse.invoice.expireDate }}
- Hash: {{ parse.invoice.hash }} -

- {% endraw %} -
- Pay - Cancel -
-
- Not enough funds! - Cancel -
-
-
- {% raw %} - -

- Authenticate with {{ parse.lnurlauth.domain }}? -

- -

- For every website and for every LNbits wallet, a new keypair will be - deterministically generated so your identity can't be tied to your - LNbits wallet or linked across websites. No other data will be - shared with {{ parse.lnurlauth.domain }}. -

-

Your public key for {{ parse.lnurlauth.domain }} is:

-

- {{ parse.lnurlauth.pubkey }} -

-
- Login - Cancel -
-
- {% endraw %} -
-
- {% raw %} - -

- {{ parse.lnurlpay.domain }} is requesting {{ - parse.lnurlpay.maxSendable | msatoshiFormat }} - {{LNBITS_DENOMINATION}} - -
- and a {{parse.lnurlpay.commentAllowed}}-char comment -
-

-

- {{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }} is - requesting
- between {{ parse.lnurlpay.minSendable | msatoshiFormat }} and - {{ parse.lnurlpay.maxSendable | msatoshiFormat }} - {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} - -
- and a {{parse.lnurlpay.commentAllowed}}-char comment -
-

- -
-

- {{ parse.lnurlpay.description }} -

-

- -

-
-
-
- {% endraw %} - - {% raw %} + + {% raw %} +
+ + + Withdraw from {{receive.lnurl.domain}} + + Create invoice + + Cancel
-
- -
-
-
- Send {{LNBITS_DENOMINATION}} - Cancel -
- - {% endraw %} -
-
- - - -
- Read + + + + +
+ Copy invoice CancelClose
- -
- + + {% endraw %} + + + + +
+
+ {% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", + "")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} +
+
+ {{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% + raw %} +
+ +

+ Description: {{ parse.invoice.description }}
+ Expire date: {{ parse.invoice.expireDate }}
+ Hash: {{ parse.invoice.hash }} +

+ {% endraw %} +
+ Pay + Cancel +
+
+ Not enough funds! + Cancel +
+
+
+ {% raw %} + +

+ Authenticate with {{ parse.lnurlauth.domain }}? +

+ +

+ For every website and for every LNbits wallet, a new keypair + will be deterministically generated so your identity can't be + tied to your LNbits wallet or linked across websites. No other + data will be shared with {{ parse.lnurlauth.domain }}. +

+

Your public key for {{ parse.lnurlauth.domain }} is:

+

+ {{ parse.lnurlauth.pubkey }} +

+
+ Login + Cancel +
+
+ {% endraw %} +
+
+ {% raw %} + +

+ {{ parse.lnurlpay.domain }} is requesting {{ + parse.lnurlpay.maxSendable | msatoshiFormat }} + {{LNBITS_DENOMINATION}} + +
+ and a {{parse.lnurlpay.commentAllowed}}-char comment +
+

+

+ {{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }} + is requesting
+ between + {{ parse.lnurlpay.minSendable | msatoshiFormat }} and + {{ parse.lnurlpay.maxSendable | msatoshiFormat }} + {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} + +
+ and a {{parse.lnurlpay.commentAllowed}}-char comment +
+

+ +
+

+ {{ parse.lnurlpay.description }} +

+

+ +

+
+
+
+ {% endraw %} + + {% raw %} +
+
+ +
+
+
+ Send {{LNBITS_DENOMINATION}} + Cancel +
+
+ {% endraw %} +
+
+ + + +
+ Read + Cancel +
+
+
+ + + +
+ + Cancel + +
+
+
+
+
+ + + +
- -
- - Cancel -
-
-
-
- +
+ Cancel +
+ + - - -
- -
-
- Cancel + + + + + + + + -
-
-
+ + + + + - - - - - - - - - - - - - + + - - - - - -
Warning
-

- Login functionality to be released in a future update, for now, - make sure you bookmark this page for future access to your - wallet! -

-

- This service is in BETA, and we hold no responsibility for people losing - access to funds. {% if service_fee > 0 %} To encourage you to run your - own LNbits installation, any balance on {% raw %}{{ - disclaimerDialog.location.host }}{% endraw %} will incur a charge of - {{ service_fee }}% service fee per week. {% endif %} -

-
- Copy wallet URL - I understand -
-
-
- {% endblock %} + + +
Warning
+

+ Login functionality to be released in a future update, for now, + make sure you bookmark this page for future access to your + wallet! +

+

+ This service is in BETA, and we hold no responsibility for people + losing access to funds. {% if service_fee > 0 %} To encourage you to + run your own LNbits installation, any balance on {% raw %}{{ + disclaimerDialog.location.host }}{% endraw %} will incur a charge of + {{ service_fee }}% service fee per week. {% endif + %} +

+
+ Copy wallet URL + I understand +
+
+
+ {% endblock %} +
+
diff --git a/lnbits/core/views/admin_api.py b/lnbits/core/views/admin_api.py new file mode 100644 index 00000000..20eaeea3 --- /dev/null +++ b/lnbits/core/views/admin_api.py @@ -0,0 +1,74 @@ +from http import HTTPStatus +from typing import Optional + +from fastapi import Body, Depends +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_wallet +from lnbits.core.models import User +from lnbits.core.services import update_cached_settings, update_wallet_balance +from lnbits.decorators import check_admin, check_super_user +from lnbits.server import server_restart +from lnbits.settings import AdminSettings, EditableSettings + +from .. import core_app +from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings + + +@core_app.get("/admin/api/v1/settings/") +async def api_get_settings( + user: User = Depends(check_admin), # type: ignore +) -> Optional[AdminSettings]: + admin_settings = await get_admin_settings(user.super_user) + return admin_settings + + +@core_app.put( + "/admin/api/v1/settings/", + status_code=HTTPStatus.OK, + dependencies=[Depends(check_admin)], +) +async def api_update_settings(data: EditableSettings): + await update_admin_settings(data) + update_cached_settings(dict(data)) + return {"status": "Success"} + + +@core_app.delete( + "/admin/api/v1/settings/", + status_code=HTTPStatus.OK, + dependencies=[Depends(check_super_user)], +) +async def api_delete_settings() -> None: + await delete_admin_settings() + server_restart.set() + + +@core_app.get( + "/admin/api/v1/restart/", + status_code=HTTPStatus.OK, + dependencies=[Depends(check_super_user)], +) +async def api_restart_server() -> dict[str, str]: + server_restart.set() + return {"status": "Success"} + + +@core_app.put( + "/admin/api/v1/topup/", + status_code=HTTPStatus.OK, + dependencies=[Depends(check_super_user)], +) +async def api_topup_balance( + id: str = Body(...), amount: int = Body(...) +) -> dict[str, str]: + try: + await get_wallet(id) + except: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist." + ) + + await update_wallet_balance(wallet_id=id, amount=int(amount)) + + return {"status": "Success"} diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 21342d68..1c67474f 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -1,12 +1,11 @@ import asyncio -import binascii import hashlib import json import time import uuid from http import HTTPStatus from io import BytesIO -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, Optional, Tuple, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import async_timeout @@ -26,19 +25,20 @@ from fastapi.params import Body from loguru import logger from pydantic import BaseModel from pydantic.fields import Field -from sse_starlette.sse import EventSourceResponse, ServerSentEvent -from starlette.responses import HTMLResponse, StreamingResponse +from sse_starlette.sse import EventSourceResponse +from starlette.responses import StreamingResponse from lnbits import bolt11, lnurl from lnbits.core.models import Payment, Wallet from lnbits.decorators import ( WalletTypeInfo, + check_admin, get_key_type, require_admin_key, require_invoice_key, ) -from lnbits.helpers import url_for, urlsafe_short_hash -from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE, WALLET +from lnbits.helpers import url_for +from lnbits.settings import get_wallet_class, settings from lnbits.utils.exchange_rates import ( currencies, fiat_amount_as_satoshis, @@ -47,14 +47,11 @@ from lnbits.utils.exchange_rates import ( from .. import core_app, db from ..crud import ( - create_payment, get_payments, get_standalone_payment, get_total_balance, - get_wallet, get_wallet_for_key, save_balance_check, - update_payment_status, update_wallet, ) from ..services import ( @@ -70,6 +67,11 @@ from ..services import ( from ..tasks import api_invoice_listeners +@core_app.get("/api/v1/health", status_code=HTTPStatus.OK) +async def health(): + return + + @core_app.get("/api/v1/wallet") async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): if wallet.wallet_type == 0: @@ -82,35 +84,6 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)): return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat} -@core_app.put("/api/v1/wallet/balance/{amount}") -async def api_update_balance( - amount: int, wallet: WalletTypeInfo = Depends(get_key_type) -): - if wallet.wallet.user not in LNBITS_ADMIN_USERS: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user" - ) - - payHash = urlsafe_short_hash() - await create_payment( - wallet_id=wallet.wallet.id, - checking_id=payHash, - payment_request="selfPay", - payment_hash=payHash, - amount=amount * 1000, - memo="selfPay", - fee=0, - ) - await update_payment_status(checking_id=payHash, pending=False) - updatedWallet = await get_wallet(wallet.wallet.id) - - return { - "id": wallet.wallet.id, - "name": wallet.wallet.name, - "balance": amount, - } - - @core_app.put("/api/v1/wallet/{new_name}") async def api_update_wallet( new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -168,16 +141,14 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): if data.description_hash or data.unhashed_description: try: description_hash = ( - binascii.unhexlify(data.description_hash) - if data.description_hash - else b"" + bytes.fromhex(data.description_hash) if data.description_hash else b"" ) unhashed_description = ( - binascii.unhexlify(data.unhashed_description) + bytes.fromhex(data.unhashed_description) if data.unhashed_description else b"" ) - except binascii.Error: + except ValueError: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="'description_hash' and 'unhashed_description' must be a valid hex strings", @@ -186,7 +157,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): else: description_hash = b"" unhashed_description = b"" - memo = data.memo or LNBITS_SITE_TITLE + memo = data.memo or settings.lnbits_site_title if data.unit == "sat": amount = int(data.amount) @@ -242,7 +213,8 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): lnurl_response = resp["reason"] else: lnurl_response = True - except (httpx.ConnectError, httpx.RequestError): + except (httpx.ConnectError, httpx.RequestError) as ex: + logger.error(ex) lnurl_response = False return { @@ -416,7 +388,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet): yield dict(data=jdata, event=typ) except asyncio.CancelledError as e: - logger.debug(f"CancelledError on listener {uid}: {e}") + logger.debug(f"removing listener for wallet {uid}") api_invoice_listeners.pop(uid) task.cancel() return @@ -686,13 +658,9 @@ async def img(request: Request, data): ) -@core_app.get("/api/v1/audit") -async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)): - if wallet.wallet.user not in LNBITS_ADMIN_USERS: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user" - ) - +@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)]) +async def api_auditor(): + WALLET = get_wallet_class() total_balance = await get_total_balance() error_message, node_balance = await WALLET.status() diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 31a7b030..ed7b156d 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -13,15 +13,9 @@ from starlette.responses import HTMLResponse, JSONResponse from lnbits.core import db from lnbits.core.models import User -from lnbits.decorators import check_user_exists +from lnbits.decorators import check_admin, check_user_exists from lnbits.helpers import template_renderer, url_for -from lnbits.settings import ( - LNBITS_ADMIN_USERS, - LNBITS_ALLOWED_USERS, - LNBITS_CUSTOM_LOGO, - LNBITS_SITE_TITLE, - SERVICE_FEE, -) +from lnbits.settings import get_wallet_class, settings from ...helpers import get_valid_extensions from ..crud import ( @@ -44,7 +38,7 @@ async def favicon(): @core_html_routes.get("/", response_class=HTMLResponse) -async def home(request: Request, lightning: str = None): +async def home(request: Request, lightning: str = ""): return template_renderer().TemplateResponse( "core/index.html", {"request": request, "lnurl": lightning} ) @@ -117,7 +111,6 @@ async def wallet( user_id = usr.hex if usr else None wallet_id = wal.hex if wal else None wallet_name = nme - service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE if not user_id: user = await get_user((await create_account()).id) @@ -128,12 +121,18 @@ async def wallet( return template_renderer().TemplateResponse( "error.html", {"request": request, "err": "User does not exist."} ) - if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS: + if ( + len(settings.lnbits_allowed_users) > 0 + and user_id not in settings.lnbits_allowed_users + and user_id not in settings.lnbits_admin_users + and user_id != settings.super_user + ): return template_renderer().TemplateResponse( "error.html", {"request": request, "err": "User not authorized."} ) - if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS: + if user_id == settings.super_user or user_id in settings.lnbits_admin_users: user.admin = True + if not wallet_id: if user.wallets and not wallet_name: # type: ignore wallet = user.wallets[0] # type: ignore @@ -163,7 +162,7 @@ async def wallet( "request": request, "user": user.dict(), # type: ignore "wallet": userwallet.dict(), - "service_fee": service_fee, + "service_fee": settings.lnbits_service_fee, "web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore }, ) @@ -185,7 +184,7 @@ async def lnurl_full_withdraw(request: Request): "k1": "0", "minWithdrawable": 1000 if wallet.withdrawable_balance else 0, "maxWithdrawable": wallet.withdrawable_balance, - "defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}", + "defaultDescription": f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}", "balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id), } @@ -284,12 +283,12 @@ async def manifest(usr: str): raise HTTPException(status_code=HTTPStatus.NOT_FOUND) return { - "short_name": LNBITS_SITE_TITLE, - "name": LNBITS_SITE_TITLE + " Wallet", + "short_name": settings.lnbits_site_title, + "name": settings.lnbits_site_title + " Wallet", "icons": [ { - "src": LNBITS_CUSTOM_LOGO - if LNBITS_CUSTOM_LOGO + "src": settings.lnbits_custom_logo + if settings.lnbits_custom_logo else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png", "type": "image/png", "sizes": "900x900", @@ -311,3 +310,19 @@ async def manifest(usr: str): for wallet in user.wallets ], } + + +@core_html_routes.get("/admin", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_admin)): # type: ignore + WALLET = get_wallet_class() + _, balance = await WALLET.status() + + return template_renderer().TemplateResponse( + "admin/index.html", + { + "request": request, + "user": user.dict(), + "settings": settings.dict(), + "balance": balance, + }, + ) diff --git a/lnbits/core/views/public_api.py b/lnbits/core/views/public_api.py index 9b0ebc98..56afc176 100644 --- a/lnbits/core/views/public_api.py +++ b/lnbits/core/views/public_api.py @@ -6,7 +6,6 @@ from urllib.parse import urlparse from fastapi import HTTPException from loguru import logger from starlette.requests import Request -from starlette.responses import HTMLResponse from lnbits import bolt11 diff --git a/lnbits/db.py b/lnbits/db.py index 7d294197..1bef7bf2 100644 --- a/lnbits/db.py +++ b/lnbits/db.py @@ -11,7 +11,7 @@ from sqlalchemy import create_engine from sqlalchemy_aio.base import AsyncConnection from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore -from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL +from lnbits.settings import settings POSTGRES = "POSTGRES" COCKROACH = "COCKROACH" @@ -121,8 +121,8 @@ class Database(Compat): def __init__(self, db_name: str): self.name = db_name - if LNBITS_DATABASE_URL: - database_uri = LNBITS_DATABASE_URL + if settings.lnbits_database_url: + database_uri = settings.lnbits_database_url if database_uri.startswith("cockroachdb://"): self.type = COCKROACH @@ -162,14 +162,16 @@ class Database(Compat): ) ) else: - if os.path.isdir(LNBITS_DATA_FOLDER): - self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3") + if os.path.isdir(settings.lnbits_data_folder): + self.path = os.path.join( + settings.lnbits_data_folder, f"{self.name}.sqlite3" + ) database_uri = f"sqlite:///{self.path}" self.type = SQLITE else: raise NotADirectoryError( - f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created" - f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again" + f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created" + f" - please 'mkdir {settings.lnbits_data_folder}' and try again" ) logger.trace(f"database {self.type} added for {self.name}") self.schema = self.name diff --git a/lnbits/decorators.py b/lnbits/decorators.py index d4aa63ae..9aeace40 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -14,11 +14,7 @@ from starlette.requests import Request from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.models import User, Wallet from lnbits.requestvars import g -from lnbits.settings import ( - LNBITS_ADMIN_EXTENSIONS, - LNBITS_ADMIN_USERS, - LNBITS_ALLOWED_USERS, -) +from lnbits.settings import settings class KeyChecker(SecurityBase): @@ -150,8 +146,12 @@ async def get_key_type( status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." ) if ( - LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS - ) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): + wallet.wallet.user != settings.super_user + and wallet.wallet.user not in settings.lnbits_admin_users + ) and ( + settings.lnbits_admin_extensions + and pathname in settings.lnbits_admin_extensions + ): raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="User not authorized for this extension.", @@ -227,17 +227,45 @@ async def require_invoice_key( async def check_user_exists(usr: UUID4) -> User: g().user = await get_user(usr.hex) + if not g().user: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." ) - if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS: + if ( + len(settings.lnbits_allowed_users) > 0 + and g().user.id not in settings.lnbits_allowed_users + and g().user.id not in settings.lnbits_admin_users + and g().user.id != settings.super_user + ): raise HTTPException( status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." ) - if LNBITS_ADMIN_USERS and g().user.id in LNBITS_ADMIN_USERS: - g().user.admin = True - return g().user + + +async def check_admin(usr: UUID4) -> User: + user = await check_user_exists(usr) + if user.id != settings.super_user and not user.id in settings.lnbits_admin_users: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="User not authorized. No admin privileges.", + ) + user.admin = True + user.super_user = False + if user.id == settings.super_user: + user.super_user = True + + return user + + +async def check_super_user(usr: UUID4) -> User: + user = await check_admin(usr) + if user.id != settings.super_user: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="User not authorized. No super user privileges.", + ) + return user diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py index 6e55b3df..cec7434d 100644 --- a/lnbits/extensions/bleskomat/helpers.py +++ b/lnbits/extensions/bleskomat/helpers.py @@ -2,7 +2,6 @@ import base64 import hashlib import hmac import urllib -from binascii import unhexlify from http import HTTPStatus from typing import Dict @@ -19,7 +18,7 @@ def generate_bleskomat_lnurl_signature( payload: str, api_key_secret: str, api_key_encoding: str = "hex" ): if api_key_encoding == "hex": - key = unhexlify(api_key_secret) + key = bytes.fromhex(api_key_secret) elif api_key_encoding == "base64": key = base64.b64decode(api_key_secret) else: diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html index 2a7160bd..5bbd826e 100644 --- a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html +++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html @@ -9,11 +9,13 @@

This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the - open-source DIY Bleskomat ATM project as well as the - commercial Bleskomat ATM. + commercial Bleskomat ATM.

Connect Your Bleskomat ATM
diff --git a/lnbits/extensions/boltcards/README.md b/lnbits/extensions/boltcards/README.md index b86de62c..93457066 100644 --- a/lnbits/extensions/boltcards/README.md +++ b/lnbits/extensions/boltcards/README.md @@ -2,30 +2,28 @@ This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow. -Tutorial +Tutorial **Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!*** -***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp). + +***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp). ## About the keys -Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set: +Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set, but for the security reasons all five keys should be changed from default (empty) state. The keys directly needed by this extension are: -One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1. +- One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1. -One for calculating CMAC (c parameter), let's called it file key, key #02 or K2. +- One for calculating CMAC (c parameter), let's called it file key, key #02 or K2. -The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app. +The key #00, K0 (also know as auth key) is used as authentification key. It is not directly needed by this extension, but should be filled in order to write the keys in cooperation with Boltcard NFC Card Creator. In this case also K3 is set to same value as K1 and K4 as K2, so all keys are changed from default values. Keep that in your mind in case you ever need to reset the keys manually. ***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!*** -## Setting the card - bolt-nfc-android-app (easy way) -So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer. -- Read the card with the app. Note UID so you can fill it in the extension later. -- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}` - - `{external_id}` should be replaced with the External ID found in the LNbits dialog. +## Setting the card - Boltcard NFC Card Creator (easy way) +Updated for v0.1.3 - Add new card in the extension. - Set a max sats per transaction. Any transaction greater than this amount will be rejected. @@ -33,14 +31,31 @@ So far, regarding the keys, the app can only write a new key set on an empty car - Set a card name. This is just for your reference inside LNbits. - Set the card UID. This is the unique identifier on your NFC card and is 7 bytes. - If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field. + - Otherwise read it with the Android app (Advanced -> Read NFC) and paste it to the field. - Advanced Options - Card Keys (k0, k1, k2) will be automatically generated if not explicitly set. - - Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode. - - GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead. + - Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure). + - GENERATE KEY button fill the keys randomly. - Click CREATE CARD button -- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys. -- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys. -- Tap the NFC card to write the keys to the card. +- Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager. + - Now you can scan the QR code with the Android app (Create Bolt Card -> SCAN QR CODE). + - Or you can Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL). +- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY! + +## Erasing the card - Boltcard NFC Card Creator +Updated for v0.1.3 + +Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys but also disable the SUN function and do the complete erase so the card can be use again as a static tag (or set as a new Bolt Card, ofc). + +- Click the QR code button next to a card to view its details and select WIPE +- OR click the red cross icon on the right side to reach the same +- In the android app (Advanced -> Reset Keys) + - Click SCAN QR CODE to scan the QR + - Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON) +- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY! +- Now if there is all success the card can be safely delete from LNbits (but keep the keys backuped anyway; batter safe than brick). + +If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4). ## Setting the card - computer (hard way) @@ -48,7 +63,7 @@ Follow the guide. The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000` -Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0. +Then fill up the card parameters in the extension. Card Auth key (K0) can be filled in the extension just for the record. Initical counter can be 0. ## Setting the card - android NXP app (hard way) - If you don't know the card ID, use NXP TagInfo app to find it out. @@ -70,4 +85,4 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be omi - Save & Write - Scan with compatible Wallet -This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0. +This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0. diff --git a/lnbits/extensions/boltcards/crud.py b/lnbits/extensions/boltcards/crud.py index c541346e..4fae31f9 100644 --- a/lnbits/extensions/boltcards/crud.py +++ b/lnbits/extensions/boltcards/crud.py @@ -171,6 +171,9 @@ async def get_hit(hit_id: str) -> Optional[Hit]: async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]: + if len(cards_ids) == 0: + return [] + q = ",".join(["?"] * len(cards_ids)) rows = await db.fetchall( f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,) @@ -265,6 +268,9 @@ async def get_refund(refund_id: str) -> Optional[Refund]: async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]: + if len(hits_ids) == 0: + return [] + q = ",".join(["?"] * len(hits_ids)) rows = await db.fetchall( f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,) diff --git a/lnbits/extensions/boltcards/lnurl.py b/lnbits/extensions/boltcards/lnurl.py index 6fb9ad8d..43d64eee 100644 --- a/lnbits/extensions/boltcards/lnurl.py +++ b/lnbits/extensions/boltcards/lnurl.py @@ -1,21 +1,13 @@ -import base64 -import hashlib -import hmac import json import secrets from http import HTTPStatus -from io import BytesIO -from typing import Optional from urllib.parse import urlparse -from embit import bech32, compact from fastapi import Request from fastapi.param_functions import Query from fastapi.params import Depends, Query -from lnurl import Lnurl, LnurlWithdrawResponse from lnurl import encode as lnurl_encode # type: ignore from lnurl.types import LnurlPayMetadata # type: ignore -from loguru import logger from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import HTMLResponse @@ -33,7 +25,6 @@ from .crud import ( get_hit, get_hits_today, spend_hit, - update_card, update_card_counter, update_card_otp, ) @@ -108,15 +99,27 @@ async def lnurl_callback( pr: str = Query(None), k1: str = Query(None), ): + if not k1: + return {"status": "ERROR", "reason": "Missing K1 token"} + hit = await get_hit(k1) - card = await get_card(hit.card_id) + if not hit: - return {"status": "ERROR", "reason": f"LNURL-pay record not found."} - if hit.id != k1: - return {"status": "ERROR", "reason": "Bad K1"} + return { + "status": "ERROR", + "reason": "Record not found for this charge (bad k1)", + } if hit.spent: - return {"status": "ERROR", "reason": f"Payment already claimed"} - invoice = bolt11.decode(pr) + return {"status": "ERROR", "reason": "Payment already claimed"} + if not pr: + return {"status": "ERROR", "reason": "Missing payment request"} + + try: + invoice = bolt11.decode(pr) + except: + return {"status": "ERROR", "reason": "Failed to decode payment request"} + + card = await get_card(hit.card_id) hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000)) try: await pay_invoice( @@ -126,8 +129,8 @@ async def lnurl_callback( extra={"tag": "boltcard", "tag": hit.id}, ) return {"status": "OK"} - except: - return {"status": "ERROR", "reason": f"Payment failed"} + except Exception as exc: + return {"status": "ERROR", "reason": f"Payment failed - {exc}"} # /boltcards/api/v1/auth?a=00000000000000000000000000000000 diff --git a/lnbits/extensions/boltcards/static/js/index.js b/lnbits/extensions/boltcards/static/js/index.js index 11df222a..880a555e 100644 --- a/lnbits/extensions/boltcards/static/js/index.js +++ b/lnbits/extensions/boltcards/static/js/index.js @@ -149,6 +149,7 @@ new Vue({ }, qrCodeDialog: { show: false, + wipe: false, data: null } } @@ -259,9 +260,10 @@ new Vue({ }) }) }, - openQrCodeDialog(cardId) { + openQrCodeDialog(cardId, wipe) { var card = _.findWhere(this.cards, {id: cardId}) this.qrCodeDialog.data = { + id: card.id, link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp, name: card.card_name, uid: card.uid, @@ -272,6 +274,17 @@ new Vue({ k3: card.k1, k4: card.k2 } + this.qrCodeDialog.data_wipe = JSON.stringify({ + action: 'wipe', + k0: card.k0, + k1: card.k1, + k2: card.k2, + k3: card.k1, + k4: card.k2, + uid: card.uid, + version: 1 + }) + this.qrCodeDialog.wipe = wipe this.qrCodeDialog.show = true }, addCardOpen: function () { @@ -397,8 +410,16 @@ new Vue({ let self = this let cards = _.findWhere(this.cards, {id: cardId}) + Quasar.utils.exportFile( + cards.card_name + '.json', + this.qrCodeDialog.data_wipe, + 'application/json' + ) + LNbits.utils - .confirmDialog('Are you sure you want to delete this card') + .confirmDialog( + "Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!" + ) .onOk(function () { LNbits.api .request( diff --git a/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html b/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html index be4e2ae8..ec5ed575 100644 --- a/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html +++ b/lnbits/extensions/boltcards/templates/boltcards/_api_docs.html @@ -11,6 +11,7 @@ Manage your Bolt Cards self custodian way
More details diff --git a/lnbits/extensions/boltcards/templates/boltcards/index.html b/lnbits/extensions/boltcards/templates/boltcards/index.html index 6398c20e..2091718c 100644 --- a/lnbits/extensions/boltcards/templates/boltcards/index.html +++ b/lnbits/extensions/boltcards/templates/boltcards/index.html @@ -48,6 +48,7 @@ +