Compare commits

..
Author SHA1 Message Date
dni ⚡ c820456500 feat: add openapi-generators for sdk's 2025-04-10 12:29:41 +02:00
383 changed files with 34013 additions and 60699 deletions
-7
View File
@@ -23,10 +23,3 @@ mypy.ini
package-lock.json package-lock.json
package.json package.json
pytest.ini pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
+63 -117
View File
@@ -1,94 +1,11 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/ #For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
###### .env ONLY SETTINGS ############
######################################
# The following settings are ONLY set in your .env file.
# They are NOT managed by the Admin UI and are not stored in the database.
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# === Admin Settings ===
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
HOST=127.0.0.1
PORT=5000
# VERSION=
# USER_AGENT=
# === LNbits ===
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
FORWARDED_ALLOW_IPS="*"
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# LNBITS_TITLE="LNbits API"
# LNBITS_PATH="folder/path"
# === Auth Configurations ===
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
# === Funding Source ===# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
######################################
###### END .env ONLY SETTINGS ########
######################################
###################################### ######################################
####### Auth Configurations ########## ####### Auth Configurations ##########
###################################### ######################################
# Secret Key: will default to the hash of the super user.
# !!!!! It is strongly recommended that you set your own strong random value !!!!
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600 AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth # Possible authorization methods: user-id-only, username-password, nostr-auth-nip98, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password" AUTH_ALLOWED_METHODS="user-id-only, username-password"
@@ -99,6 +16,14 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
########### Admin Settings ########### ########### Admin Settings ###########
###################################### ######################################
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
# The rest of the settings will be stored in your database and you will be able to change them
# only through the Admin UI.
# Disable this and clear `settings` table from database to make LNbits use this config file again.
LNBITS_ADMIN_UI=true
# Change theme # Change theme
LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform" LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
@@ -107,14 +32,22 @@ LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run f
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber" LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
HOST=127.0.0.1
PORT=5000
###################################### ######################################
########## Funding Source ############ ########## Funding Source ############
###################################### ######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities, # VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file. # just so you can see the UI before dealing with this file.
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
# FUNDING_SOURCE_MAX_RETRIES=4
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources # Invoice expiry for LND, CLN, Eclair, LNbits funding sources
LIGHTNING_INVOICE_EXPIRY=3600 LIGHTNING_INVOICE_EXPIRY=3600
@@ -128,20 +61,6 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
SPARK_URL=http://localhost:9737/rpc SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken SPARK_TOKEN=myaccesstoken
#CLNRest (using runes)
CLNREST_URL=https://127.0.0.1:3010
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
# CoreLightningWallet # CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
@@ -182,14 +101,9 @@ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet # BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002 BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
# HEXSTRING instead of path also possible BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon" BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
# HEXSTRING instead of path also possible BOLTZ_CLIENT_WALLET="lnbits"
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
# ZBDWallet # ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/ ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
@@ -233,12 +147,6 @@ BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRIN
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
# BREEZ_USE_TRAMPOLINE=true # BREEZ_USE_TRAMPOLINE=true
# BreezLiquidSdkWallet
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
# BREEZ_LIQUID_API_KEY=""
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
# Google OAuth Config # Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token # Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
@@ -255,12 +163,15 @@ GITHUB_CLIENT_SECRET=""
KEYCLOAK_CLIENT_ID="" KEYCLOAK_CLIENT_ID=""
KEYCLOAK_CLIENT_SECRET="" KEYCLOAK_CLIENT_SECRET=""
KEYCLOAK_DISCOVERY_URL="" KEYCLOAK_DISCOVERY_URL=""
KEYCLOAK_CLIENT_CUSTOM_ORG=""
KEYCLOAK_CLIENT_CUSTOM_ICON=""
###################################### ######################################
# uvicorn variable, uncomment to allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
# FORWARDED_ALLOW_IPS="*"
# Server security, rate limiting ips, blocked ips, allowed ips # Server security, rate limiting ips, blocked ips, allowed ips
LNBITS_RATE_LIMIT_NO="200" LNBITS_RATE_LIMIT_NO="200"
LNBITS_RATE_LIMIT_UNIT="minute" LNBITS_RATE_LIMIT_UNIT="minute"
@@ -271,7 +182,8 @@ LNBITS_BLOCKED_IPS=""
# if set new users will not be able to create accounts # if set new users will not be able to create accounts
LNBITS_ALLOWED_USERS="" LNBITS_ALLOWED_USERS=""
LNBITS_ADMIN_USERS="" LNBITS_ADMIN_USERS=""
# ID of the super user. The user ID must exist.
# SUPER_USER=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient" LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
@@ -304,6 +216,26 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# Hides wallet api, extensions can choose to honor # Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false LNBITS_HIDE_API=false
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
# for both PostgreSQL and CockroachDB, you'll need to install
# psycopg2 as an additional dependency
LNBITS_DATA_FOLDER="./data"
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
# the service fee (in percent) # the service fee (in percent)
LNBITS_SERVICE_FEE=0.0 LNBITS_SERVICE_FEE=0.0
# the wallet where fees go to # the wallet where fees go to
@@ -331,3 +263,17 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
###################################### ######################################
###### Logging and Development ####### ###### Logging and Development #######
###################################### ######################################
DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg custom: https://demo.lnbits.com/lnurlp/link/fH59GD
+25 -5
View File
@@ -5,6 +5,9 @@ inputs:
description: "Python Version" description: "Python Version"
required: true required: true
default: "3.10" default: "3.10"
poetry-version:
description: "Poetry Version"
default: "1.8.5"
node-version: node-version:
description: "Node Version" description: "Node Version"
default: "20.x" default: "20.x"
@@ -21,16 +24,33 @@ runs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
# cache poetry install via pip
cache: "pip"
- name: Install uv - name: Set up Poetry ${{ inputs.poetry-version }}
uses: astral-sh/setup-uv@v6 uses: abatilo/actions-poetry@v2
with: with:
enable-cache: true poetry-version: ${{ inputs.poetry-version }}
python-version: ${{ inputs.python-version }}
- name: Setup a local virtual environment (if no poetry.toml file)
shell: bash
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies - name: Install the project dependencies
shell: bash shell: bash
run: uv sync --locked --all-extras --dev run: |
poetry env use python${{ inputs.python-version }}
poetry install
# needed for conv tests
poetry add psycopg2-binary
- name: Use Node.js ${{ inputs.node-version }} - name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }} if: ${{ (inputs.npm == 'true') }}
+1
View File
@@ -36,6 +36,7 @@ LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}" LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH export LNBITS_EXTENSIONS_PATH
export LNBITS_ADMIN_UI=true
# Define the LNbits URL # Define the LNbits URL
URL="http://0.0.0.0:5000" URL="http://0.0.0.0:5000"
-120
View File
@@ -1,120 +0,0 @@
name: Build LNbits AppImage
on:
workflow_call:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
tag_name:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install system deps and uv
run: |
sudo apt-get update
sudo apt-get install -y libfuse2
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
shell: bash
- name: Cache uv and venv
uses: actions/cache@v4
with:
path: |
~/.cache/uv
.venv
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock', 'pyproject.toml') }}
- name: Prepare packaging & clone LNbits
run: |
mv .github/packaging packaging
mkdir -p packaging/linux/AppDir/usr
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
- name: Build LNbits binary (uv + PyInstaller)
run: |
cd packaging/linux/AppDir/usr/lnbits
uv sync --all-extras --no-dev
uv pip install pyinstaller
uv run pyinstaller \
--onefile \
--name lnbits \
--hidden-import=embit \
--collect-all embit \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
--collect-binaries breez_sdk \
--collect-all breez_sdk_liquid \
--collect-binaries breez_sdk_liquid \
--collect-all aiosqlite \
--hidden-import=passlib.handlers.bcrypt \
"$(uv run which lnbits)"
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
# keep AppDir slim
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \
! -name 'lnbits' \
-exec rm -rf {} +
# Build AppImage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ inputs.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage \
--updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" \
packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
# 🔎 quick audit: show glibc versions referenced by the binary
echo "Runner glibc:"
ldd --version | head -n1 || true
echo "Symbols needed by lnbits:"
strings "$APPIMAGE_NAME" | grep -o 'GLIBC_[0-9.]*' | sort -u || true
shell: bash
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -19
View File
@@ -12,13 +12,6 @@ jobs:
lint: lint:
uses: ./.github/workflows/lint.yml uses: ./.github/workflows/lint.yml
test-frontend:
needs: [ lint ]
uses: ./.github/workflows/make.yml
with:
make: test-frontend
npm: true
test-api: test-api:
needs: [ lint ] needs: [ lint ]
strategy: strategy:
@@ -27,7 +20,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
custom-pytest: "uv run pytest tests/api" custom-pytest: "poetry run pytest tests/api"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }} db-url: ${{ matrix.db-url }}
secrets: secrets:
@@ -41,7 +34,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
custom-pytest: "uv run pytest tests/wallets" custom-pytest: "poetry run pytest tests/wallets"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }} db-url: ${{ matrix.db-url }}
secrets: secrets:
@@ -55,7 +48,7 @@ jobs:
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"] db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
with: with:
custom-pytest: "uv run pytest tests/unit" custom-pytest: "poetry run pytest tests/unit"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }} db-url: ${{ matrix.db-url }}
secrets: secrets:
@@ -82,16 +75,9 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
backend-wallet-class: backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
with: with:
custom-pytest: "uv run pytest tests/regtest" custom-pytest: "poetry run pytest tests/regtest"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }} backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets: secrets:
@@ -102,6 +88,8 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml uses: ./.github/workflows/jmeter.yml
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
-12
View File
@@ -51,15 +51,3 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push boltz
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.boltz
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits-boltz:${{ inputs.tag }}
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
+6 -1
View File
@@ -8,6 +8,11 @@ on:
required: true required: true
default: "3.10" default: "3.10"
type: string type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs: jobs:
jmeter: jmeter:
@@ -25,7 +30,7 @@ jobs:
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw" LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: | run: |
uv run lnbits & poetry run lnbits &
sleep 10 sleep 10
- name: setup java version - name: setup java version
-3
View File
@@ -53,6 +53,3 @@ jobs:
with: with:
make: checkbundle make: checkbundle
npm: true npm: true
poetry:
uses: ./.github/workflows/poetry.yml
+2 -2
View File
@@ -14,14 +14,14 @@ on:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'pyproject.toml' - 'pyproject.toml'
- 'uv.lock' - 'poetry.lock'
- '.github/workflows/nix.yml' - '.github/workflows/nix.yml'
pull_request: pull_request:
paths: paths:
- 'flake.nix' - 'flake.nix'
- 'flake.lock' - 'flake.lock'
- 'pyproject.toml' - 'pyproject.toml'
- 'uv.lock' - 'poetry.lock'
jobs: jobs:
nix: nix:
+72
View File
@@ -0,0 +1,72 @@
name: Build LNbits AppImage DMG
on:
release:
types: [published]
jobs:
build-linux-package:
runs-on: ubuntu-latest
steps:
# Step 1: Checkout the repository
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: Install Dependencies
- name: Install Dependencies
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
sudo apt-get update
sudo apt-get install -y libfuse2
shell: bash
# Step 3: Clone LNbits Repository
- name: Clone LNbits
run: |
mv .github/packaging packaging
mkdir -p packaging/linux/AppDir/usr
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
shell: bash
# Step 4: Make the AppImage Asset
- name: Make Asset
run: |
cd packaging/linux/AppDir/usr/lnbits
poetry install
poetry run pip install pyinstaller
# Build the LNbits binary
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
! -name 'dist' \
! -name 'lnbits' \
-exec rm -rf {} +
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
TAG_NAME=${{ github.event.release.tag_name }}
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
chmod +x "$APPIMAGE_NAME"
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
shell: bash
# Step 5: Upload Linux Release Asset
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-24
View File
@@ -1,24 +0,0 @@
name: poetry
on:
workflow_call:
inputs:
python-version:
description: "python version"
type: string
default: "3.10"
jobs:
poetry:
name: run poetry install to check lock file
runs-on: "ubuntu-24.04"
steps:
- uses: actions/checkout@v4
- name: set up python ${{ inputs.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
- name: install poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
poetry install
+6 -8
View File
@@ -32,18 +32,18 @@ jobs:
run: | run: |
docker build -t lnbits/lnbits . docker build -t lnbits/lnbits .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Setup Regtest - name: Setup Regtest
run: | run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker cd docker
chmod +x ./start-regtest chmod +x ./tests
./start-regtest ./tests
sudo chmod -R a+rwx . sudo chmod -R a+rwx .
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Run pytest - name: Run pytest
uses: pavelzw/pytest-action@v2 uses: pavelzw/pytest-action@v2
env: env:
@@ -63,8 +63,6 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001 LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee" LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082 ECLAIR_URL: http://127.0.0.1:8082
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits
-30
View File
@@ -10,29 +10,7 @@ permissions:
jobs: jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
@@ -41,7 +19,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1 - name: Install dependencies for building secp256k1
@@ -53,10 +30,3 @@ jobs:
uses: JRubics/poetry-publish@v1.15 uses: JRubics/poetry-publish@v1.15
with: with:
pypi_token: ${{ secrets.PYPI_API_KEY }} pypi_token: ${{ secrets.PYPI_API_KEY }}
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-20
View File
@@ -13,8 +13,6 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
@@ -23,17 +21,8 @@ jobs:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --generate-notes --draft gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -43,7 +32,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest: docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -53,7 +41,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1 - name: Install dependencies for building secp256k1
@@ -65,10 +52,3 @@ jobs:
uses: JRubics/poetry-publish@v1.15 uses: JRubics/poetry-publish@v1.15
with: with:
pypi_token: ${{ secrets.PYPI_API_KEY }} pypi_token: ${{ secrets.PYPI_API_KEY }}
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+1 -1
View File
@@ -1,4 +1,4 @@
name: tests pytest name: tests
on: on:
workflow_call: workflow_call:
+2 -2
View File
@@ -7,7 +7,6 @@ __pycache__
.mypy_cache .mypy_cache
.vscode .vscode
*-lock.json *-lock.json
.python-version
*.egg *.egg
*.egg-info *.egg-info
@@ -43,6 +42,8 @@ lnbits/static/bundle.min.css.old
lnbits/static/bundle-components.min.js.old lnbits/static/bundle-components.min.js.old
lnbits/upgrades lnbits/upgrades
docker docker
generated
openapi.json*
# Nix # Nix
*result* *result*
@@ -61,4 +62,3 @@ dist
# jetbrains # jetbrains
.idea .idea
.venv
+5 -5
View File
@@ -2,7 +2,7 @@ exclude: '^lnbits/static/bundle.*|^docs/.*|^lnbits/static/vendor/.*|^lnbits/exte
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v4.3.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -14,16 +14,16 @@ repos:
- id: mixed-line-ending - id: mixed-line-ending
- id: check-case-conflict - id: check-case-conflict
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 25.1.0 rev: 24.2.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.10 rev: v0.3.2
hooks: hooks:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.7.4 rev: "v4.0.0-alpha.8"
hooks: hooks:
- id: prettier - id: prettier
types_or: [css, javascript, html, json] types_or: [css, javascript, html, json]
+18 -8
View File
@@ -2,20 +2,24 @@ FROM python:3.12-slim-bookworm AS builder
RUN apt-get clean RUN apt-get clean
RUN apt-get update RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname automake RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN curl -LsSf https://astral.sh/uv/install.sh | sh RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app WORKDIR /app
# Only copy the files required to install the dependencies # Only copy the files required to install the dependencies
COPY pyproject.toml uv.lock ./ COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN mkdir data RUN mkdir data
RUN uv sync --all-extras ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
RUN poetry install --only main
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
@@ -28,19 +32,25 @@ RUN apt-get update && apt-get -y upgrade && \
apt-get -y install postgresql-client-14 postgresql-client-common && \ apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/* apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
ENV PATH="/root/.local/bin:$PATH" ENV PATH="/root/.local/bin:$PATH"
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
COPY . . COPY . .
COPY --from=builder /app/.venv .venv COPY --from=builder /app/.venv .venv
RUN uv sync --all-extras RUN poetry install --only main
ENV LNBITS_PORT="5000" ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0" ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"] CMD ["sh", "-c", "poetry run lnbits --port $LNBITS_PORT --host $LNBITS_HOST"]
-33
View File
@@ -1,33 +0,0 @@
ARG LNBITS_TAG=latest
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y netcat-openbsd
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:$PATH"
# Reinstall dependencies just in case (needed for CMD usage)
RUN uv sync --all-extras
# LNbits + boltzd configuration
ENV LNBITS_PORT="5000"
ENV LNBITS_HOST="0.0.0.0"
ENV LNBITS_BACKEND_WALLET_CLASS="BoltzWallet"
ENV FUNDING_SOURCE_MAX_RETRIES=10
ENV BOLTZ_CLIENT_ENDPOINT="127.0.0.1:9002"
ENV BOLTZ_CLIENT_MACAROON="/root/.boltz/macaroons/admin.macaroon"
ENV BOLTZ_CLIENT_CERT="/root/.boltz/tls.cert"
ENV BOLTZ_CLIENT_WALLET="lnbits"
EXPOSE 5000
# Entrypoint to start boltzd and LNbits
COPY dockerboltz.sh /dockerboltz.sh
RUN chmod +x /dockerboltz.sh
CMD ["/dockerboltz.sh"]
+30 -30
View File
@@ -9,68 +9,64 @@ check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest test: test-unit test-wallets test-api test-regtest
prettier: prettier:
uv run ./node_modules/.bin/prettier --write . poetry run ./node_modules/.bin/prettier --write .
pyright: pyright:
uv run ./node_modules/.bin/pyright poetry run ./node_modules/.bin/pyright
mypy: mypy:
uv run mypy poetry run mypy
black: black:
uv run black . poetry run black .
ruff: ruff:
uv run ruff check . --fix poetry run ruff check . --fix
checkruff: checkruff:
uv run ruff check . poetry run ruff check .
checkprettier: checkprettier:
uv run ./node_modules/.bin/prettier --check . poetry run ./node_modules/.bin/prettier --check .
checkblack: checkblack:
uv run black --check . poetry run black --check .
checkeditorconfig: checkeditorconfig:
editorconfig-checker editorconfig-checker
dev: dev:
uv run lnbits --reload poetry run lnbits --reload
docker: docker:
docker build -t lnbits/lnbits . docker build -t lnbits/lnbits .
test-frontend:
npm install
npm test
test-wallets: test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
uv run pytest tests/wallets poetry run pytest tests/wallets
test-unit: test-unit:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
uv run pytest tests/unit poetry run pytest tests/unit
test-api: test-api:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
uv run pytest tests/api poetry run pytest tests/api
test-regtest: test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
uv run pytest tests/regtest poetry run pytest tests/regtest
test-migration: test-migration:
LNBITS_ADMIN_UI=True \ LNBITS_ADMIN_UI=True \
@@ -78,18 +74,18 @@ test-migration:
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=5002 \ PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=5002 \ PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \ LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \ LNBITS_ADMIN_UI=False \
timeout 5s uv run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi timeout 5s poetry run lnbits --host 0.0.0.0 --port 5002 || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \ LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
uv run python tools/conv.py poetry run python tools/conv.py
migration: migration:
uv run python tools/conv.py poetry run python tools/conv.py
openapi: openapi:
LNBITS_ADMIN_UI=False \ LNBITS_ADMIN_UI=False \
@@ -98,14 +94,18 @@ openapi:
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=5003 \ PORT=5003 \
uv run lnbits & poetry run lnbits &
sleep 15 sleep 15
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all - curl -s http://0.0.0.0:5003/openapi.json | poetry run openapi-spec-validator --errors=all -
# kill -9 %1 # kill -9 %1
bak: generate:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres rm -rf generated
# mkdir generated
rm -f openapi.json
wget localhost:5000/openapi.json
npm run generate
rm openapi.json
sass: sass:
npm run sass npm run sass
@@ -113,7 +113,7 @@ sass:
bundle: bundle:
npm install npm install
npm run bundle npm run bundle
uv run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
checkbundle: checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
@@ -130,8 +130,8 @@ checkbundle:
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall" @echo "Uninstall the hook with poetry run pre-commit uninstall"
uv run pre-commit install poetry run pre-commit install
pre-commit: pre-commit:
uv run pre-commit run --all-files poetry run pre-commit run --all-files
+23 -37
View File
@@ -1,46 +1,38 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <picture >
<picture> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png"> <img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px"> </picture>
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) ![phase: beta](https://img.shields.io/badge/phase-beta-C41E3A) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-08A04B) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" /> ![Lightning network wallet](https://i.imgur.com/DeIiO0y.png)
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit # The world's most powerful suite of bitcoin tools.
> Run it for yourself, for your community, or as part of a larger stack. ## Run for yourself, for others, or as part of a stack.
## What is LNbits? LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
LNbits is a lightweight Python server that sits on top of your Lightning funding source. It gives you safe, isolated wallets, a clean API, and an extension system for rapidly adding features - without locking you into a single node implementation. The Inspiration for LNBits came from ideas pioneered by **OpenNode** and **LNPay** — both today work as funding sources for LNbits. LNbits is a Python server that sits on top of any funding source. It can be used as:
## What you can do with LNbits - Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
- Extendable platform for exploring Lightning network functionality via the LNbits extension framework
- Part of a development stack via LNbits API
- Fallback wallet for the LNURL scheme
- Instant wallet for LN demonstrations
- **Harden app security:** Create per-wallet API keys so individual apps never touch your full balance. LNbits can run on top of almost all Lightning funding sources.
- **Extend functionality fast:** Install extensions to explore and ship Lightning features with minimal code.
- **Build into your stack:** Use the LNbits HTTP API to integrate payments, wallets, and accounting.
- **Cover LNURL flows:** Use LNbits as a reliable fallback wallet for LNURL.
- **Demo in minutes:** Spin up instant wallets for workshops, proofs-of-concept, and user testing.
## Funding sources See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
LNbits runs on top of most Lightning backends. Choose the one you already operate - or swap later without changing your app architecture. Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
- Read the [funding source guide](https://docs.lnbits.org/guide/wallets.html) LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
## Learn more
- Video series on [Youtube](https://www.youtube.com/@lnbits)
- Introduction Video [LNBits V1](https://www.youtube.com/watch?v=PFAHKxvgI9Y&t=19s)
## Running LNbits ## Running LNbits
See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup. Test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr.
Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo.lnbits.com), or on [lnbits.com](https://lnbits.com) software as a service, where you can spin up an LNbits instance for 21sats per hr. See the [install guide](https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md) for details on installation and setup.
## LNbits account system ## LNbits account system
@@ -74,15 +66,9 @@ As well as working great in a browser, LNbits has native IoS and Android apps as
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px"> <img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits ## Tip us
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable. If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=7c3aed&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
[docs]: https://github.com/lnbits/lnbits/wiki [docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
-26
View File
@@ -1,26 +0,0 @@
#!/bin/bash
set -e
boltzd --standalone --referralId lnbits &
# Capture boltzd PID to monitor if needed
BOLTZ_PID=$!
# Wait for boltzd to start
for i in {1..10}; do
if nc -z localhost 9002; then
echo "boltzd is up!"
break
fi
echo "Waiting for boltzd to start..."
sleep 1
done
# Optional: check if still not up
if ! nc -z localhost 9002; then
echo "boltzd did not start successfully."
exit 1
fi
echo "Starting LNbits on $LNBITS_HOST:$LNBITS_PORT..."
exec uv run lnbits --port "$LNBITS_PORT" --host "$LNBITS_HOST" --forwarded-allow-ips='*'
-1
View File
@@ -4,7 +4,6 @@ color_scheme: dark
logo: "/logos/lnbits-full-inverse.png" logo: "/logos/lnbits-full-inverse.png"
search_enabled: true search_enabled: true
url: https://docs.lnbits.org url: https://docs.lnbits.org
markdown: gfm
aux_links: aux_links:
"LNbits on GitHub": "LNbits on GitHub":
- "//github.com/lnbits/lnbits" - "//github.com/lnbits/lnbits"
+6 -6
View File
@@ -11,13 +11,13 @@ Thanks for contributing :)
# Run # Run
Follow the [Option 2 (recommended): UV](https://docs.lnbits.org/guide/installation.html) Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install uv and other dependencies. guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with: Then you can start LNbits uvicorn server with:
```bash ```bash
uv run lnbits poetry run lnbits
``` ```
Or you can use the following to start uvicorn with hot reloading enabled: Or you can use the following to start uvicorn with hot reloading enabled:
@@ -25,7 +25,7 @@ Or you can use the following to start uvicorn with hot reloading enabled:
```bash ```bash
make dev make dev
# or # or
uv run lnbits --reload poetry run lnbits --reload
``` ```
You might need the following extra dependencies on clean installation of Debian: You might need the following extra dependencies on clean installation of Debian:
@@ -50,7 +50,7 @@ make install-pre-commit-hook
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies: This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash ```bash
uv sync --all-extras --dev poetry install
npm i npm i
``` ```
@@ -69,7 +69,7 @@ make format
Run mypy checks: Run mypy checks:
```bash ```bash
make mypy poetry run mypy
``` ```
Run everything: Run everything:
+6 -2
View File
@@ -42,10 +42,14 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others. DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are available in `pyproject.toml`. Getting the LNbits project to accept a new dependency is time consuming and uncertain, and may result in your extension NOT being made available to others.
If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty` or `uv`: If for some reason your extensions must have a new python package to work, and its nees are not met in `pyproject.toml`, you can add a new package using `poerty`:
```sh
$ poetry add <package>
```
**But we need an extra step to make sure LNbits doesn't break in production.** **But we need an extra step to make sure LNbits doesn't break in production.**
Dependencies need to be added to `pyproject.toml`, then tested by running on `uv` and `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`. Dependencies need to be added to `pyproject.toml`, then tested by running on `poetry` compatibility can be tested with `nix build .#checks.x86_64-linux.vmTest`.
## SQLite to PostgreSQL migration ## SQLite to PostgreSQL migration
+49 -105
View File
@@ -1,134 +1,78 @@
--- ---
layout: default layout: default
title: Admin UI title: Admin UI
nav_order: 1 nav_order: 4
--- ---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> # Admin UI
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) From there on the settings from the database are used.
# LNBits Admin UI # Super User
[What you can do](#what-you-can-do-with-the-admin-ui) · [First Run](#first-run-and-super-user-id) · [Enable/Disable](#enabling-or-disabling-the-admin-ui) · [Reset](#reset-to-defaults) · [Allowed Users](#allowed-users) · [Guides](#additional-guides) 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.
**We introduced the Admin UI as the new default to make setup simpler and more straightforward**. Instead of hand editing the `.env` file, you configure key server settings directly in the frontend with clear labels and guardrails. Also only the super user can brrrr satoshis to different wallets.
<ins>On a fresh install the Admin UI is enabled by default</ins>, and at first launch you are prompted to create **Super User** credentials so that sensitive operations, such as switching funding sources, remain in trusted hands. When the Admin UI is enabled, configuration is written to and read from the database; for all settings managed by the UI, the parameters in `.env` are largely no longer used. If you disable the Admin UI, the `.env` file becomes the single source of truth again. 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.
For privileged actions and role details see **[Super User](./super_user.md)** & [User Roles](./user_roles.md) The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
For a complete reference of legacy variables consult **[.env.example](../../.env.example)**.
<img width="900" height="640" alt="grafik" src="https://github.com/user-attachments/assets/d8852b4b-21be-446f-a1e7-d3eb794d3505" /> We also added a decorator for the API routes to check for super user.
> [!WARNING] 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`
> Some settings remain `.env` only. Use **[.env.example](../../.env.example#L3-L87)** as the authoritative reference for those variables.
## What you can do with the Admin UI # Admin Users
- Switch funding sources and other server level settings environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
- Manage who can access LNbits (**[Allowed Users](#allowed-users)**) Admin Users can change settings in the admin ui as well, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessible. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
- Promote or demote Admin Users
- Gate extensions to Admins only or disable them globally
- Adjust balances with credit or debit
- Adjust site customization
> [!NOTE] # Allowed Users
> See **[Super User](./super_user.md)** for the role and permission differences compared to Admin Users.
## First run and Super User ID environment variable: `LNBITS_ALLOWED_USERS`, comma-separated list of user ids
By defining this users, LNbits will no longer be usable by the public, only defined users and admins can then access the LNbits frontend.
On first start with the Admin UI enabled you will be prompted to generate a Super User. Setting this environment variable also disables account creation.
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
<img width="1573" height="976" alt="Admin_UI_first_install" src="https://github.com/user-attachments/assets/05aa634f-06ec-4a4d-a5c6-d90927c90991" /> # How to activate
If you need to read it from disk later: ```
$ sudo systemctl stop lnbits.service
$ cd ~/lnbits
$ sudo nano .env
```
```bash -> set: `LNBITS_ADMIN_UI=true`
cat /lnbits/data/.super_user
# example Now start LNbits once in the terminal window
```
$ poetry run lnbits
```
You can now `cat` the Super User ID:
```
$ cat data/.super_user
123de4bfdddddbbeb48c8bc8382fe123 123de4bfdddddbbeb48c8bc8382fe123
``` ```
> [!WARNING] You can access your super user account at `/wallet?usr=super_user_id`. You just have to append it to your normal LNbits web domain.
> For security reasons, Super Users and Admin users must authenticate with credentials (username and password).
After login you will see **Settings** and **Users** in the sidebar between **Wallets** and **Extensions**, plus a role badge in the top left. After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
<img width="1353" height="914" alt="grafik" src="https://github.com/user-attachments/assets/06bb4f36-a23a-4058-87ec-60440d322c25" /> Here you can design the interface, it has credit/debit to change wallets balances 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.
## Enabling or disabling the Admin UI Do not forget
The Admin UI is enabled by default on new installs. To change the state: ```
sudo systemctl start lnbits.service
```
1. Stop LNbits 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.
```bash
sudo systemctl stop lnbits.service
```
2. Edit your `.env`
```
cd ~/lnbits
sudo nano .env
```
3. Set one of
```
# Enable Admin UI
LNBITS_ADMIN_UI=true
# Disable Admin UI
LNBITS_ADMIN_UI=false
```
4. Start LNbits
```
sudo systemctl start lnbits.service
```
> [!NOTE]
> With the Admin UI enabled, config is DB-backed and UI-managed settings ignore .env. Disable it to revert to [.env](../../.env.example) as the single source of truth.
## Reset to defaults
Using `Reset to defaults` in the Admin UI wipes stored settings. After a restart, a new `Super User` is created and the old one is no longer valid.
## Allowed Users
When set **at least one**, LNbits becomes private: only the listed users and Admins can access the frontend. Account creation is disabled automatically. You can also disable account creation explicitly.
<img width="1889" height="870" alt="grafik" src="https://github.com/user-attachments/assets/89011b75-a267-44ea-971a-1517968b7af5" />
> [!WARNING]
> Assign your own account first when enabling **Allowed Users** to avoid locking yourself out. If you do get locked out, use your Super User to recover access.
## Additional Guides
- **[Backend Wallets](./wallets.md)** — Explore options to fund your LNbits instance.
- **[User Roles](./user_roles.md)** — Overview of existing roles in LNbits.
- **[Funding sources](./funding-sources-table.md)** — What is available and how to configure each.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
LNbits empowers everyone with modular, open source tools for building Bitcoin based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
-6
View File
@@ -1,9 +1,3 @@
---
layout: default
title: Extension Install
nav_order: 1
---
# Extension Install # Extension Install
Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide. Anyone can create an extension by following the [example extension](https://github.com/lnbits/example) and [making extensions](https://github.com/lnbits/lnbits/blob/main/docs/devs/extensions.md) dev guide.
@@ -1,9 +1,3 @@
---
layout: default
title: FastAPI extension upgrade
nav_order: 1
---
## Defining a route with path parameters ## Defining a route with path parameters
**old:** **old:**
-85
View File
@@ -1,85 +0,0 @@
---
layout: default
title: Wallet comparison
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table
LNbits lets you choose **how your wallets are funded** — from fully self-custodial nodes to simple hosted services. You can switch the funding source **without touching your apps, users, or extensions**. That means you can start fast, learn, and later upgrade to more control and privacy when you are ready.
**Why this matters**
- **Flexibility:** Pick the backend that fits your skills and constraints today, change it later with minimal friction.
- **Speed to ship:** Use a hosted option to get live quickly; move to a node when you need more control.
- **Scalability:** Match cost and maintenance to your stage — from hobby to production.
- **Privacy and compliance:** Choose between self-custody and provider-managed options depending on your requirements.
Below is a side-by-side comparison of Lightning funding sources you can use with LNbits.
> [!NOTE]
> “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
## LNbits Lightning Network Funding Sources Comparison Table
| **Funding Source** | **Custodial Type** | **KYC Required** | **Technical Knowledge Needed** | **Node Hosting Required** | **Privacy Level** | **Liquidity Management** | **Ease of Setup** | **Maintenance Effort** | **Cost Implications** | **Scalability** | **Notes** |
| ------------------------------ | ------------------------ | ------------------- | ------------------------------ | ------------------------- | ----------------- | ------------------------ | ----------------- | ---------------------- | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ |
| **LND (gRPC)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | gRPC interface for LND; suitable for advanced integrations. |
| **CoreLightning (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Requires setting up and managing your own CLN node. |
| **Phoenixd** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Moderate | Low | Minimal fees | Medium | Mobile wallet backend; suitable for mobile integrations. |
| **Nostr Wallet Connect (NWC)** | Custodial | Depends on provider | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur fees | Medium | Connects via Nostr protocol; depends on provider's policies. |
| **Boltz** | Self-custodial | ❌ | Medium | ❌ | Medium | Provider-managed | Moderate | Moderate | Minimal fees | Medium | Uses submarine swaps; connects to Boltz client. |
| **LND (REST)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for LND; suitable for web integrations. |
| **CoreLightning REST** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | REST interface for CLN; suitable for web integrations. |
| **LNbits (another instance)** | Custodial | Depends on host | Low | ❌ | Variable | Provider-managed | Easy | Low | May incur hosting fees | Medium | Connects to another LNbits instance; depends on host's policies. |
| **Alby** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Browser extension wallet; suitable for web users. |
| **Breez SDK** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Moderate | Low | Minimal fees | Medium | SDK for integrating Breez wallet functionalities. |
| **OpenNode** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for merchants. |
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **Eclair (ACINQ)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Connects via API; you run and manage your Eclair node. |
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
---
### Notes for readers
- These are typical characteristics; your exact experience may vary by configuration and provider policy.
- Pick based on your constraints: compliance (KYC), privacy, ops effort, and time-to-ship.
---
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./User_Roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources_table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
+156 -484
View File
@@ -1,142 +1,37 @@
--- ---
layout: default layout: default
title: Installation title: Basic installation
nav_order: 1 nav_order: 2
--- ---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation # Basic installation
> [!NOTE] Note that by default LNbits uses SQLite as its database, which is simple and effective but you can configure it to use PostgreSQL instead which is also described in a section below.
> **Default DB:** LNbits uses SQLite by default (simple & effective). You can switch to PostgreSQL — see the section below.
## Table of contents ## Option 1: AppImage (LInux)
- [Option 1: AppImage (Linux)](#option-1-appimage-linux) ### AppImage (Linux)
- [Option 2: UV (recommended for developers)](#option-2-uv-recommended-for-developers)
- [Option 2a (Legacy): Poetry — Replaced by UV](#option-2a-legacy-poetry--replaced-by-uv)
- [Option 3: Install script (Debian/Ubuntu)](#option-3-install-script-debianubuntu)
- [Option 4: Nix](#option-4-nix)
- [Option 5: Docker](#option-5-docker)
- [Option 6: Fly.io](#option-6-flyio)
- [Troubleshooting](#troubleshooting)
- [Optional: PostgreSQL database](#optional-postgresql-database)
- [Using LNbits](#using-lnbits)
- [Additional guides](#additional-guides)
- [Update LNbits (all methods)](#update-lnbits-all-methods)
- [SQLite → PostgreSQL migration](#sqlite--postgresql-migration)
- [LNbits as a systemd service](#lnbits-as-a-systemd-service)
- [Reverse proxy with automatic HTTPS (Caddy)](#reverse-proxy-with-automatic-https-caddy)
- [Apache2 reverse proxy over HTTPS](#apache2-reverse-proxy-over-https)
- [Nginx reverse proxy over HTTPS](#nginx-reverse-proxy-over-https)
- [HTTPS without a reverse proxy (self-signed)](#https-without-a-reverse-proxy-self-signed)
- [LNbits on Umbrel behind Tor](#lnbits-on-umbrel-behind-tor)
- [FreeBSD notes](#freebsd-notes)
## Option 1: AppImage (Linux) Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
**Quickstart**
1. Download latest AppImage from [releases](https://github.com/lnbits/lnbits/releases) **or** run:
```sh ```sh
sudo apt-get install jq libfuse2 sudo apt-get install libfuse2
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
chmod +x LNbits-latest.AppImage chmod +x LNbits-latest.AppImage
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here ./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
``` ```
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage. LNbits will create a folder for db and extension files in the folder the AppImage runs from.
> [!NOTE] ## Option 2: Poetry (recommended for developers)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2: UV (recommended for developers) It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
> [!IMPORTANT] ### Install Python 3.12
> **It is recommended to use the latest version of UV & Make sure you have Python version 3.12 installed.**
### Verify Python ## Option 2 (recommended): Poetry
```sh It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
python3 --version
```
### Install UV
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
```
### Install LNbits
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
git checkout main
uv sync --all-extras
cp .env.example .env
# Optional: set funding source and other options in .env (e.g., `nano .env`)
```
### Run the server
```sh
uv run lnbits
# To change port/host: uv run lnbits --port 9000 --host 0.0.0.0
# Add --debug to the command above and set DEBUG=true in .env for verbose output
```
### LNbits CLI
```sh
# Useful for superuser ID, updating extensions, etc.
uv run lnbits-cli --help
```
### Update LNbits
```sh
cd lnbits
# Stop LNbits with Ctrl + X or your service manager
# sudo systemctl stop lnbits
# Update code
git pull --rebase
uv sync --all-extras
uv run lnbits
```
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level
> [!NOTE]
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Option 2a (Legacy): Poetry — _Replaced by UV_
<details>
<summary><strong>Poetry install and update (legacy workflow)</summary>
This legacy section is preserved for older environments.
**UV is the recommended (and faster) tool** for new installs. Use Poetry only if you have personal preferences or must support an older workflow.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949)
> **It is recommended to use the latest version of Poetry & Make sure you have Python version 3.12 installed.**
### Verify Python version ### Verify Python version
@@ -151,7 +46,7 @@ python3 --version
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH" curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
``` ```
### Install LNbits ### install LNbits
```sh ```sh
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
@@ -167,14 +62,15 @@ cp .env.example .env
```sh ```sh
poetry run lnbits poetry run lnbits
# To change port/host: poetry run lnbits --port 9000 --host 0.0.0.0 # To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
# Add --debug to help troubleshooting (also set DEBUG=true in .env) # adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
# Note that you have to add the line DEBUG=true in your .env file, too.
``` ```
#### LNbits CLI #### LNbits-cli
```sh ```sh
# A very useful terminal client for getting the superuser ID, updating extensions, etc. # A very useful terminal client for getting the supersuer ID, updating extensions, etc
poetry run lnbits-cli --help poetry run lnbits-cli --help
``` ```
@@ -182,37 +78,14 @@ poetry run lnbits-cli --help
```sh ```sh
cd lnbits cd lnbits
# Stop LNbits with Ctrl + X or with your service manager # Stop LNbits with `ctrl + x`
# sudo systemctl stop lnbits git pull
# Keep your poetry install up to date, this can be done with `poetry self update`
# Update LNbits
git pull --rebase
# Check your Poetry Python version
poetry env list
# If version is less than 3.12, update it:
poetry env use python3.12
poetry env remove python3.X
poetry env list
# Reinstall and start
poetry install --only main poetry install --only main
poetry run lnbits # Start LNbits with `poetry run lnbits`
``` ```
#### Use Admin UI → Extensions → "Update All" to bring extensions up to the proper level ## Option 2: Install script (on Debian/Ubuntu)
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949)
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
## Option 3: Install script (Debian/Ubuntu)
<details>
<summary><strong>Show install script</strong> (one-line setup)</summary>
```sh ```sh
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh && wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
@@ -220,58 +93,29 @@ chmod +x lnbits.sh &&
./lnbits.sh ./lnbits.sh
``` ```
- You can use `./lnbits.sh` to run, but for more control: `cd lnbits` and use `uv run lnbits` (see Option 2). Now visit `0.0.0.0:5000` to make a super-user account.
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> ## Option 3: Nix
## Option 4: Nix
<details>
<summary><strong>Show Nix instructions</strong> (flakes, cachix, run)</summary>
```sh ```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download) # Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon --yes sh <(curl -L https://nixos.org/nix/install) --daemon
# Enable nix-command and flakes experimental features for nix: # Enable nix-command and flakes experimental features for nix:
grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \ echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
# Add user to Nix
grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
# Restart daemon so changes apply
sudo systemctl restart nix-daemon
# Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
# Make data directory and persist data/extension folders
mkdir data
PROJECT_DIR="$(pwd)"
{
echo "export PYTHONPATH=\"$PROJECT_DIR/ns:\$PYTHONPATH\""
echo "export LNBITS_DATA_FOLDER=\"$PROJECT_DIR/data\""
echo "export LNBITS_EXTENSIONS_PATH=\"$PROJECT_DIR\""
} >> ~/.bashrc
grep -qxF '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ~/.bashrc || \
echo '. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' >> ~/.bashrc
. ~/.bashrc
# Add cachix for cached binaries # Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install nix-env -iA cachix -f https://cachix.org/api/v1/install
cachix use lnbits cachix use lnbits
# Build LNbits # Clone and build LNbits
git clone https://github.com/lnbits/lnbits.git
cd lnbits
nix build nix build
mkdir data
``` ```
#### Running the server #### Running the server
@@ -285,42 +129,24 @@ but you can also set the env variables or pass command line arguments:
```sh ```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI. # .env variables are currently passed when running, but LNbits can be managed with the admin UI.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
# Once you have created a user, you can set as the super_user # Once you have created a user, you can set as the super_user
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) ## Option 4: Docker
> **Next steps**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details> use latest version from docker hub
## Option 5: Docker
<details>
<summary><strong>Show Docker instructions</strong> (official image, volumes, extensions)</summary>
**Use latest image**
```sh ```sh
docker pull lnbits/lnbits docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits \ docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
``` ```
- The LNbits Docker image ships **without any extensions**; by default, any extensions you install are stored **inside the container** and will be **lost** when the container is removed, so you should set `LNBITS_EXTENSIONS_PATH` to a directory thats **mapped to a persistent host volume** so extensions **survive rebuilds/recreates**—for example: build the image yourself
```sh
docker run ... -e "LNBITS_EXTENSIONS_PATH='/app/data/extensions'" --volume ${PWD}/data/:/app/data ...
```
**Build image yourself**
```sh ```sh
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
@@ -328,39 +154,18 @@ cd lnbits
docker build -t lnbits/lnbits . docker build -t lnbits/lnbits .
cp .env.example .env cp .env.example .env
mkdir data mkdir data
docker run --detach --publish 5000:5000 --name lnbits \ docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
``` ```
You can optionally override the install extras for both **Poetry** and **UV** to include optional features during build or setup: ## Option 5: Fly.io
- with Poetry, pass extras via the `POETRY_INSTALL_ARGS` Docker build-arg (e.g., to enable the **Breez** funding source: `docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .`); Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
- with UV, enable extras during environment sync (e.g., locally run `uv sync --extra breez` or `uv sync --all-extras`), and—**if your Dockerfile supports it**—you can mirror the same at build time via a build-arg such as `UV_SYNC_ARGS` (example pattern: `docker build --build-arg UV_SYNC_ARGS="--extra breez" -t lnbits/lnbits .`).
**Enable Breez funding source at build** First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
```sh Then, install the Fly.io CLI onto your device [here](https://fly.io/docs/getting-started/installing-flyctl/).
docker build --build-arg POETRY_INSTALL_ARGS="-E breez" -t lnbits/lnbits .
```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=494949) After install is complete, the command will output a command you should copy/paste/run to get `fly` into your `$PATH`. Something like:
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNBits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
</details>
## Option 6: Fly.io
<details>
<summary><strong>Deploy LNbits on Fly.io (free tier friendly)</summary>
**Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.**
1. Create an account at [Fly.io](https://fly.io).
2. Install the Fly.io CLI ([guide](https://fly.io/docs/getting-started/installing-flyctl/)).
``` ```
flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl flyctl was installed successfully to /home/ubuntu/.fly/bin/flyctl
@@ -369,9 +174,9 @@ Manually add the directory to your $HOME/.bash_profile (or similar)
export PATH="$FLYCTL_INSTALL/bin:$PATH" export PATH="$FLYCTL_INSTALL/bin:$PATH"
``` ```
3. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`. You can either run those commands, then `source ~/.bash_profile` or, if you don't, you'll have to call Fly from `~/.fly/bin/flyctl`.
- Once installed, run the following commands. Once installed, run the following commands.
``` ```
git clone https://github.com/lnbits/lnbits.git git clone https://github.com/lnbits/lnbits.git
@@ -385,16 +190,9 @@ You'll be prompted to enter an app name, region, postgres (choose no), deploy no
You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings. You'll now find a file in the directory called `fly.toml`. Open that file and modify/add the following settings.
> ![IMPORTANT](https://img.shields.io/badge/IMPORTANT-7c3aed?labelColor=494949) Note: Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`. Environment variable strings should be quoted here, so if in `.env` you have `LNBITS_ENDPOINT=https://demo.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> Be sure to replace `${PUT_YOUR_LNBITS_ENV_VARS_HERE}` with all relevant environment variables in `.env` or `.env.example`.
> Environment variable strings should be quoted here. For example, if `.env` has
> `LNBITS_ENDPOINT=https://demo.lnbits.com`, then in `fly.toml` use
> `LNBITS_ENDPOINT="https://demo.lnbits.com"`.
> ![WARNING](https://img.shields.io/badge/WARNING-ea580c?labelColor=494949) Note: Don't enter secret environment variables here. Fly.io offers secrets (via the `fly secrets` command) that are exposed as environment variables in your runtime. So, for example, if using the LND_REST funding source, you can run `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`.
> Don't enter secret environment variables here. Fly.io offers **secrets** (via `fly secrets`) that are exposed as env vars at runtime.
> Example (LND REST funding source):
> `fly secrets set LND_REST_MACAROON=<hex_macaroon_data>`
``` ```
... ...
@@ -449,49 +247,26 @@ sudo apt install python3.10-dev gcc build-essential
poetry add setuptools wheel poetry add setuptools wheel
``` ```
> ![NOTE](https://img.shields.io/badge/NOTE-3b82f6?labelColor=0b0b0b) ### Optional: PostgreSQL database
>
> **Next steps**
> Install complete → **[Running LNbits](#run-the-server)**
> Update LNbits → **[Update LNbits (all methods)](#update-lnbits-all-methods)**
## Troubleshooting If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:
```sh ```sh
sudo apt install pkg-config libffi-dev libpq-dev # on debian/ubuntu 'sudo apt-get -y install postgresql'
# or follow instructions at https://www.postgresql.org/download/linux/
# build essentials (Debian/Ubuntu) # Postgres doesn't have a default password, so we'll create one.
sudo apt install python3.10-dev gcc build-essential
# if secp256k1 build fails and you used poetry
poetry add setuptools wheel
```
</details>
</details>
## Optional: PostgreSQL database
> [!TIP]
> If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and set up a database for LNbits.
```sh
# Debian/Ubuntu: sudo apt-get -y install postgresql
# or see https://www.postgresql.org/download/linux/
# Create a password for the postgres user
sudo -i -u postgres sudo -i -u postgres
psql psql
# in psql # on psql
ALTER USER postgres PASSWORD 'myPassword'; ALTER USER postgres PASSWORD 'myPassword'; # choose whatever password you want
\q \q
# back as postgres user # on postgres user
createdb lnbits createdb lnbits
exit exit
``` ```
**Configure LNbits** You need to edit the `.env` file.
```sh ```sh
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL= # add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
@@ -502,189 +277,44 @@ LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
# Using LNbits # Using LNbits
Visit **[http://localhost:5000/](http://localhost:5000/)** (or `0.0.0.0:5000`). Now you can visit your LNbits at http://localhost:5000/.
### Option A — First-run setup in the Browser (UI) Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
1. On the **first start**, LNbits will **prompt you to Setup a SuperUser**. Then you can restart it and it will be using the new settings.
2. After creating it, youll be **redirected to the Admin UI as SuperUser**.
3. In the Admin UI, **set your funding source** (backend wallet) and other preferences.
4. **Restart LNbits** if prompted or after changing critical settings.
> [!IMPORTANT] You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
> Use the **SuperUser only** for initial setup and instance settings (funding source, configuration, Topup).
> For maintenance, create a separate **Admin** account. For everyday usage (payments, wallets, etc.), **do not use the SuperUser** — use admin or regular user accounts instead. Its a bad behaviour.
> Read more about [SuperUser](./super_user.md) and [Admin UI](./admin_ui.md)
### Option B — Configure via `.env` Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
1. Edit your `.env` with preferred settings (funding, base URL, etc.).
2. Set a funding source by configuring:
- `LNBITS_BACKEND_WALLET_CLASS`
- plus the required credentials for your chosen backend (see **[wallets.md](./wallets.md)**).
3. **Restart LNbits** to apply changes.
---
> [!NOTE]
> **Paths overview**
>
> - **SuperUser file:** `<lnbits_root>/data/.super_user`
> Example: `~/lnbits/data/.super_user` • View: `cat ~/lnbits/data/.super_user`
> - **Environment file:** `<lnbits_root>/.env` (for bare-metal installs)
> - **Docker:** bind a host directory to `/app/data`.
> On the host the SuperUser file is at `<host_data_dir>/.super_user`.
> The container reads `/app/.env` (usually bind-mounted from your project root).
> [!TIP]
> **Local Lightning test network**
> Use **Polar** to spin up a safe local Lightning environment and test LNbits without touching your live setup.
> https://lightningpolar.com/
> [!TIP]
> **API comparison before updates**
> Use **TableTown** to diff your LNbits instance against another (dev vs prod) or the upstream dev branch. Spot endpoint changes before updating.
> Crafted by [Arbadacarbayk](https://github.com/arbadacarbaYK) - a standout contribution that makes pre-release reviews fast and reliable.
> https://arbadacarbayk.github.io/LNbits_TableTown/
# Additional guides # Additional guides
## Update LNbits (all methods) ## SQLite to PostgreSQL migration
> After updating, open **Admin UI → Extensions → “Update All”** to make sure extensions match the core version. If you already have LNbits installed and running, on an SQLite database, we **highly** recommend you migrate to postgres if you are planning to run LNbits on scale.
<details> There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user (see Postgres install guide above). Additionally, your LNbits instance should run once on postgres to implement the database schema before the migration works:
<summary><strong>UV (recommended)</strong></summary>
```sh
cd lnbits
git pull --rebase
uv sync --all-extras
# restart (dev)
uv run lnbits
```
</details>
<details>
<summary><strong>Poetry (legacy)</strong></summary>
```sh
cd lnbits
git pull --rebase
# Optional: ensure Python 3.12
poetry env list
poetry env use python3.12
poetry install --only main
# restart (dev)
poetry run lnbits
```
</details>
<details>
<summary><strong>AppImage</strong></summary>
Download the latest AppImage from Releases and replace your old file **in the same directory** to keep the `./data` folder (DB, extensions).
</details>
<details>
<summary><strong>Install script (Debian/Ubuntu)</strong></summary>
```sh
# If you installed via lnbits.sh:
cd lnbits
git pull --rebase
# then use your chosen runner (UV recommended)
uv sync --all-extras
uv run lnbits
```
</details>
<details>
<summary><strong>Nix</strong></summary>
```sh
cd lnbits
git pull --rebase
nix build
# restart
nix run
```
</details>
<details>
<summary><strong>Docker (official image)</strong></summary>
```sh
docker pull lnbits/lnbits
docker stop lnbits && docker rm lnbits
docker run --detach --publish 5000:5000 --name lnbits \
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
```
</details>
<details>
<summary><strong>Docker (build yourself)</strong></summary>
```sh
cd lnbits
git pull --rebase
docker build -t lnbits/lnbits .
docker stop lnbits && docker rm lnbits
docker run --detach --publish 5000:5000 --name lnbits \
--volume ${PWD}/.env:/app/.env \
--volume ${PWD}/data/:/app/data \
lnbits/lnbits
```
</details>
<details>
<summary><strong>Fly.io</strong></summary>
```sh
# If using Dockerfile in repo (recommended)
cd lnbits
git pull --rebase
fly deploy
# Logs & shell if needed
fly logs
fly ssh console
```
</details>
## SQLite → PostgreSQL migration
> [!TIP]
> If you run on SQLite and plan to scale, migrate to Postgres.
```sh ```sh
# STOP LNbits # STOP LNbits
# Edit .env with Postgres URL # add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits" LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
# save and exit # save and exit
# START then STOP LNbits once to apply schema # START LNbits
uv run python tools/conv.py # STOP LNbits
poetry run python tools/conv.py
# or # or
make migration make migration
``` ```
- Launch LNbits again and verify. Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## LNbits as a systemd service ## LNbits as a systemd service
Create `/etc/systemd/system/lnbits.service`: Systemd is great for taking care of your LNbits instance. It will start it on boot and restart it in case it crashes. If you want to run LNbits as a systemd service on your Debian/Ubuntu/Raspbian server, create a file at `/etc/systemd/system/lnbits.service` with the following content:
``` ```
# Systemd unit for lnbits # Systemd unit for lnbits
@@ -692,14 +322,17 @@ Create `/etc/systemd/system/lnbits.service`:
[Unit] [Unit]
Description=LNbits Description=LNbits
# Optional: start after your backend # you can uncomment these lines if you know what you're doing
# it will make sure that lnbits starts after lnd (replace with your own backend service)
#Wants=lnd.service #Wants=lnd.service
#After=lnd.service #After=lnd.service
[Service] [Service]
# replace with the absolute path of your lnbits installation
WorkingDirectory=/home/lnbits/lnbits WorkingDirectory=/home/lnbits/lnbits
# Find uv path via `which uv` # same here. run `which poetry` if you can't find the poetry binary
ExecStart=/home/lnbits/.local/bin/uv run lnbits ExecStart=/home/lnbits/.local/bin/poetry run lnbits
# replace with the user that you're running lnbits on
User=lnbits User=lnbits
Restart=always Restart=always
TimeoutSec=120 TimeoutSec=120
@@ -710,23 +343,33 @@ Environment=PYTHONUNBUFFERED=1
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
Enable & start: Save the file and run the following commands:
```sh ```sh
sudo systemctl enable lnbits.service sudo systemctl enable lnbits.service
sudo systemctl start lnbits.service sudo systemctl start lnbits.service
``` ```
## Reverse proxy with automatic HTTPS (Caddy) ## Reverse proxy with automatic HTTPS using Caddy
Point your domain A-record to your server IP. Install Caddy: [Caddy install guide](https://caddyserver.com/docs/install#debian-ubuntu-raspbian) Use Caddy to make your LNbits install accessible over clearnet with a domain and https cert.
```sh Point your domain at the IP of the server you're running LNbits on, by making an `A` record.
Install Caddy on the server
https://caddyserver.com/docs/install#debian-ubuntu-raspbian
```
sudo caddy stop sudo caddy stop
```
Create a Caddyfile
```
sudo nano Caddyfile sudo nano Caddyfile
``` ```
Add: Assuming your LNbits is running on port `5000` add:
``` ```
yourdomain.com { yourdomain.com {
@@ -736,21 +379,28 @@ yourdomain.com {
} }
``` ```
Save (Ctrl+X) and start: Save and exit `CTRL + x`
```sh ```
sudo caddy start sudo caddy start
``` ```
## Apache2 reverse proxy over HTTPS ## Running behind an Apache2 reverse proxy over HTTPS
Install Apache2 and enable Apache2 mods:
```sh ```sh
apt-get install apache2 certbot apt-get install apache2 certbot
a2enmod headers ssl proxy proxy_http a2enmod headers ssl proxy proxy_http
```
Create a SSL certificate with LetsEncrypt:
```sh
certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org certbot certonly --webroot --agree-tos --non-interactive --webroot-path /var/www/html -d lnbits.org
``` ```
Create `/etc/apache2/sites-enabled/lnbits.conf`: Create an Apache2 vhost at: `/etc/apache2/sites-enabled/lnbits.conf`:
```sh ```sh
cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
@@ -777,20 +427,27 @@ cat <<EOF > /etc/apache2/sites-enabled/lnbits.conf
EOF EOF
``` ```
Restart: Restart Apache2:
```sh ```sh
service apache2 restart service apache2 restart
``` ```
## Nginx reverse proxy over HTTPS ## Running behind an Nginx reverse proxy over HTTPS
Install nginx:
```sh ```sh
apt-get install nginx certbot apt-get install nginx certbot
```
Create a SSL certificate with LetsEncrypt:
```sh
certbot certonly --nginx --agree-tos -d lnbits.org certbot certonly --nginx --agree-tos -d lnbits.org
``` ```
Create `/etc/nginx/sites-enabled/lnbits.org`: Create an nginx vhost at `/etc/nginx/sites-enabled/lnbits.org`:
```sh ```sh
cat <<EOF > /etc/nginx/sites-enabled/lnbits.org cat <<EOF > /etc/nginx/sites-enabled/lnbits.org
@@ -824,22 +481,23 @@ server {
EOF EOF
``` ```
Restart: Restart nginx:
```sh ```sh
service nginx restart service nginx restart
``` ```
--- ## Using https without reverse proxy
## HTTPS without a reverse proxy (self-signed) The most common way of using LNbits via https is to use a reverse proxy such as Caddy, nginx, or ngriok. However, you can also run LNbits via https without additional software. This is useful for development purposes or if you want to use LNbits in your local network.
Create a self-signed cert (useful for local/dev). Browsers wont trust it by default. We have to create a self-signed certificate using `mkcert`. Note that this certificate is not "trusted" by most browsers but that's fine (since you know that you have created it) and encryption is always better than clear text.
### Install mkcert #### Install mkcert
- Install instructions: [mkcert README](https://github.com/FiloSottile/mkcert) You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
- Ubuntu example:
Install mkcert on Ubuntu:
```sh ```sh
sudo apt install libnss3-tools sudo apt install libnss3-tools
@@ -848,47 +506,61 @@ chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
``` ```
### Create certificate #### Create certificate
**OpenSSL** To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh ```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
``` ```
**mkcert** (alternative) This will create two new files (`key.pem` and `cert.pem `).
Alternatively, you can use mkcert ([more info](https://kifarunix.com/how-to-create-self-signed-ssl-certificate-with-mkcert-on-ubuntu-18-04/)):
```sh ```sh
# include your local IP (e.g., 192.x.x.x) if needed # add your local IP (192.x.x.x) as well if you want to use it in your local network
mkcert localhost 127.0.0.1 ::1 mkcert localhost 127.0.0.1 ::1
``` ```
**Run with certs** You can then pass the certificate files to uvicorn when you start LNbits:
```sh ```sh
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
``` ```
## LNbits on Umbrel behind Tor ## LNbits running on Umbrel behind Tor
See this community [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604). If you want to run LNbits on your Umbrel but want it to be reached through clearnet, _Uxellodunum_ made an extensive [guide](https://community.getumbrel.com/t/guide-lnbits-without-tor/604) on how to do it.
## FreeBSD notes ## Docker installation
Issue with secp256k1 0.14.0 on FreeBSD (thanks @GitKalle): To install using docker you first need to build the docker image as:
1. Install `py311-secp256k1` with `pkg install py311-secp256k1`. ```
2. Change version in `pyproject.toml` from `0.14.0` to `0.13.2`. git clone https://github.com/lnbits/lnbits.git
3. Rewrite `poetry.lock` with `poetry lock`. cd lnbits
4. Follow install instructions with Poetry. docker build -t lnbits/lnbits .
```
--- You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
## Powered by LNbits ```
cp <lnbits_repo>/.env.example .env
```
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable. and change the configuration in `.env` as required.
If you like this project [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visiting our [Shop](https://shop.lnbits.com) Then create the data directory
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/) ```
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/) [![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login) [![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/) [![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/) mkdir data
```
Then the image can be run as:
```
docker run --detach --publish 5000:5000 --name lnbits -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits
```
Finally you can access your lnbits on your machine at port 5000.
-133
View File
@@ -1,133 +0,0 @@
---
layout: default
title: Super user
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
**Table of Contents**
- [What is the Super User?](#what-is-the-super-user)
- [When is the Super User created?](#when-is-the-super-user-created)
- [Disabeling the Admin UI](#disabeling-the-admin-ui)
- [Super User identity and storage](#super-user-identity-and-storage)
- [Security model since v1](#security-model-since-v1)
- [Admin vs Super User](#admin-vs-super-user)
- [Operational guidance](#operational-guidance)
- [Additional guides](#additional-guides)
<details>
<summary><strong>TLDR</strong></summary>
- **No Admin UI → No Super User.** The Super User (SU) exists only when `LNBITS_ADMIN_UI=true`.
- **Why SU exists:** SU can do a few high impact actions regular admins cannot, like **changing the funding source**, **restarting the server from the UI**, and **crediting or debiting accounts**.
- **Login changes since v1:** Logging in by **user ID** for SU and admins is **disabled**. On first visit after enabling the Admin UI you will be prompted to set a **username and password** for the SU.
- **Trust model:** Admins and the SU share about **99 percent of the same powers**, but the SU is the one trusted with funding source control and cannot be demoted by regular admins.
</details>
## What is the Super User?
The **Super User** is the owner-operator account of an LNbits instance. Think of it as your “break glass” operator with a few capabilities that are intentionally reserved for the person ultimately responsible for the server and the funding rails.
The SU is created alongside the [Admin UI](./admin_ui.md) and is meant to keep enviroment operations pleasant in the UI while keeping the most sensitive knobs in trusted hands.
**Key SU capabilities**
- **Change the funding source** for the instance
- **Restart the LNbits server** from the web UI
- **Credit or debit accounts** for operational corrections
> Note
> These are separated from regular admin tasks on purpose. It helps maintain least privilege and reduces the chance of accidental or malicious changes.
## Admin vs Super User
| Capability | Admin | Super User |
| ------------------------ | ---------- | ---------- |
| View Admin UI | If enabled | If enabled |
| Change funding source | — | ✓ |
| Credit or debit accounts | — | ✓ |
| Restart server from UI | — | ✓ |
| Manage users and wallets | ✓ | ✓ |
| Instance-level settings | ✓ | ✓ |
| Manage notifications | ✓ | ✓ |
| Exchange rates | ✓ | ✓ |
| View all Payments | ✓ | ✓ |
**Why both roles?**
In many teams the person running the server prefers to **delegate day-to-day admin work** while keeping funding and final authority safe. Admins can do almost everything; the SU retains the last few high risk powers.
## When is the Super User created?
- The SU is created **only** when you enable the Admin UI: `LNBITS_ADMIN_UI=true`.
- If the Admin UI is **disabled**, there is **no SU** and all SU-only UI is hidden.
## Disabeling the Admin UI
> [!IMPORTANT]
> Read the [Admin UI guide](./admin_ui.md) before Disabeling. You are turning on a management surface; do it deliberately.
Set the environment variable in your deployment:
```bash
# .env
LNBITS_ADMIN_UI=false
```
## Super User identity and storage
LNbits stores the **Super User ID** at:
```
/lnbits/data/.super_user
```
- Back this up along with the rest of `/lnbits/data` as part of your secure backup routine.
- **Changing who is the SU** can only be done by someone with **CLI access to the host OS** where LNbits runs. **Regular admins cannot revoke or replace the SU in the Admin UI.**
## Security model since v1
- **User-ID logins are disabled** for SU and admin roles.
- **Credentialed login is required:** set a **username and password** for the SU at first run of the Admin UI.
- **SU secrecy:** Regular users and admins **cannot discover the SU user ID** through normal UI flows.
## Operational guidance
These are practical tips for running a safe and friendly instance.
- It is normal to **delegate admin** duties to trusted people. Admins have about **99 percent** of SU powers for day-to-day work.
- Keep the **SU** reserved for the person legally or operationally responsible for the **funding source**.
- Use admin roles for regular day-to-day management and keep the SU for reserved SU tasks only.
## Additional guides
- **[Admin UI](./admin_ui.md)** — Manage server settings in the browser instead of editing `.env` or using the CLI for routine tasks.
- **[User Roles](./user_roles.md)** — Overview of roles and what they can do.
- **[Funding sources](./funding-sources-table.md)** — Available options and how to enable and configure them.
- **[Install LNBits](./installation.md)** — Choose your prefared way to install LNBits.
## Powered by LNbits
LNbits empowers everyone with modular, open source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
-102
View File
@@ -1,102 +0,0 @@
---
layout: default
title: User Roles
nav_order: 1
---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
### Understand **who can do what** in seconds: `Super User`, `Admin`, and `Regular User`.
**Jump to:**
[Roles at a Glance](#roles-at-a-glance) •
[Super User](#super-user--master-control) •
[Admin](#admin--day-to-day-manager) •
[Regular User](#regular-user--everyday-use) •
[Best Practices](#best-practices) •
[Additional Guides](#additional-guides)
---
## Roles at a Glance
| Capability | **Super User** (owner) | **Admin** (manager) | **Regular User** (end user) |
| -------------------------------- | :--------------------: | :-----------------: | :-------------------------: |
| Change **funding source** | ✅ | ❌ | ❌ |
| Credit/Debit any wallet | ✅ | ❌ | ❌ |
| Manage Admins & Users | ✅ | ✅ | ❌ |
| Enable/disable extensions | ✅ | ✅ | ❌ |
| Use wallets & allowed extensions | ✅ | ✅ | ✅ |
> **Plain talk:** **Super User** = Owner • **Admin** = Trusted manager • **Regular User** = End user
## Role Snapshots
### Super User — Master Control
For initial setup and rare, high-impact changes.
- Configure **server-level settings** (e.g., funding source).
- **Credit/Debit** any wallet.
- Create and manage initial **Admin(s)**.
> **Sign-in:** username + password (v1+). The old query-string login is retired.
### Admin — Day-to-Day Manager
For running the service without touching the most sensitive knobs.
- Manage **Users**, **Admins**, and **Extensions** in the Admin UI.
- Adjust security-related settings (e.g., `rate_limiter`, `ip_blocker`).
- Handle operations settings (e.g., `service_fee`, `invoice_expiry`).
- Build brand design in **Site Customization**.
- Update user accounts.
**Typical tasks:** onboarding users, enabling extensions, tidying wallets, reviewing activity.
> **Sign-in:** username + password (v1+). The old query-string login is retired.
### Regular User — Everyday Use
For using LNbits, not administering it.
- Access **personal wallets** and **allowed extensions**.
- No server/admin privileges.
**Typical tasks:** receive and send payments, use enabled extensions.
## Best Practices
- **Minimize risk:** Reserve **Super User** for rare, sensitive actions (funding source, debit/credit). Use **Admin** for daily operations.
- **Keep access tidy:** Review your Admin list occasionally; remove unused accounts.
- **Change management:** Test risky changes (like funding) in a staging setup first.
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[Super User](./super_user.md)** — Deep dive on responsibilities and safe usage patterns.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems—fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
+91 -314
View File
@@ -4,367 +4,144 @@ title: Backend wallets
nav_order: 3 nav_order: 3
--- ---
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets # Backend wallets
**LNbits is modular**: You can switch the funding source (backend wallet) **without changing anything else** in your setup. Keep your extensions, apps, users, and config as-is — just point LNbits to a different backend via environment variables. LNbits can run on top of many Lightning Network funding sources with more being added regularly.
**What stays the same when you switch backends** A backend wallet can be configured using the following LNbits environment variables:
- Your LNbits setup and extensions ### CoreLightning
- Your API keys and endpoints
- Your server and deployment setup
A backend wallet is selected and configured entirely through LNbits environment variables. See the options and variables below, and compare them here: [Funding-Source-Table.md](funding-sources-table.md) - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
- `CORELIGHTNING_RPC`: /file/path/lightning-rpc
> [!NOTE] ### CoreLightning REST
> **Terminology:** “Backend Wallet” and “Funding Source” mean the same thing — the wallet or service that funds your LNbits.
## Funding Sources - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
- `CORELIGHTNING_REST_CERT`: /home/lightning/clnrest/tls.cert
## Funding Sources ### Spark (Core Lightning)
| | | | - `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
| ----------------------------------------------- | ------------------------------------- | ------------------------------------------------- | - `SPARK_URL`: http://10.147.17.230:9737/rpc
| [CLNRest (runes)](#clnrest-runes) | [LND (REST)](#lnd-rest) | [OpenNode](#opennode) | - `SPARK_TOKEN`: secret_access_key
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
| [Fake Wallet](#fake-wallet) | | |
--- ### LND (REST)
<a id="clnrest-runes"></a> - `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
- `LND_REST_CERT`: /file/path/tls.cert
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune)) or
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest) - `LND_REST_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables** ### LND (gRPC)
- `LNBITS_BACKEND_WALLET_CLASS`: `CLNRestWallet` - `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
- `CLNREST_URL`: `https://127.0.0.1:3010` - `LND_GRPC_ENDPOINT`: ip_address
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the file) - `LND_GRPC_PORT`: port
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the file) - `LND_GRPC_CERT`: /file/path/tls.cert
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'` - `LND_GRPC_MACAROON`: /file/path/admin.macaroon or Base64/Hex
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
**Create runes (copy/paste)** You can also use an AES-encrypted macaroon (more info) instead by using
```bash - `LND_GRPC_MACAROON_ENCRYPTED`: eNcRyPtEdMaCaRoOn
# Read-only: funds, pays, invoices, info, summary, and invoice listener
lightning-cli createrune \
restrictions='[["method=listfunds","method=listpays","method=listinvoices","method=getinfo","method=summary","method=waitanyinvoice"]]' \
| jq -r .rune
```
```bash To encrypt your macaroon, run `poetry run python lnbits/wallets/macaroon/macaroon.py`.
# Invoice: max 1,000,001 msat, label must start with "LNbits", 60 req/min
lightning-cli createrune \
restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' \
| jq -r .rune
```
```bash ### LNbits
# Pay: bolt11 amount < 1001 (msat), label must start with "LNbits", 1 req/min
lightning-cli createrune \
restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
| jq -r .rune
```
```bash - `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
# Renepay: invstring amount < 1001 (msat), label must start with "LNbits", 1 req/min - `LNBITS_ENDPOINT`: e.g. https://lnbits.com
lightning-cli createrune \ - `LNBITS_KEY`: lnbitsAdminKey
restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' \
| jq -r .rune
```
Set the resulting values into: ### LNPay
- `CLNREST_READONLY_RUNE` For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret. For example, `https://mylnbits/wallet/webhook` will be the Endpoint Url that gets notified about the payment.
- `CLNREST_INVOICE_RUNE`
- `CLNREST_PAY_RUNE`
- `CLNREST_RENEPAY_RUNE`
## CoreLightning - `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
- `LNPAY_API_ENDPOINT`: https://api.lnpay.co/v1/
- `LNPAY_API_KEY`: sak_apiKey
- `LNPAY_WALLET_KEY`: waka_apiKey
**Required env vars** ### OpenNode
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningWallet` For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
- `CORELIGHTNING_RPC`: `/file/path/lightning-rpc`
## CoreLightning REST - `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
- `OPENNODE_KEY`: opennodeAdminApiKey
Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST) ### Blink
**Required env vars** For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate a Blink API key after logging in or creating a new Blink account at: https://dashboard.blink.sv. For more info visit: https://dev.blink.sv/api/auth#create-an-api-key```
- `LNBITS_BACKEND_WALLET_CLASS`: `CoreLightningRestWallet` - `LNBITS_BACKEND_WALLET_CLASS`: **BlinkWallet**
- `CORELIGHTNING_REST_URL`: `http://127.0.0.1:8185/` - `BLINK_API_ENDPOINT`: https://api.blink.sv/graphql
- `CORELIGHTNING_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex - `BLINK_WS_ENDPOINT`: wss://ws.blink.sv/graphql
- `CORELIGHTNING_REST_CERT`: `/home/lightning/clnrest/tls.cert` - `BLINK_TOKEN`: BlinkToken
## Spark (Core Lightning) ### Alby
**Required env vars** For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
- `LNBITS_BACKEND_WALLET_CLASS`: `SparkWallet` - `LNBITS_BACKEND_WALLET_CLASS`: **AlbyWallet**
- `SPARK_URL`: `http://10.147.17.230:9737/rpc` - `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `SPARK_TOKEN`: `secret_access_key` - `ALBY_ACCESS_TOKEN`: AlbyAccessToken
## LND (REST) ### Boltz
**Required env vars** This funding source connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles all lightning payments through submarine swaps on the liquid network.
You can configure the daemon to run in standalone mode by `standalone = True` in the config file or using the cli flag (`boltzd --standalone`).
Once running, you can create a liquid wallet using `boltzcli wallet create lnbits lbtc`.
- `LNBITS_BACKEND_WALLET_CLASS`: `LndRestWallet` - `LNBITS_BACKEND_WALLET_CLASS`: **BoltzWallet**
- `LND_REST_ENDPOINT`: `http://10.147.17.230:8080/` - `BOLTZ_CLIENT_ENDPOINT`: 127.0.0.1:9002
- `LND_REST_CERT`: `/file/path/tls.cert` - `BOLTZ_CLIENT_MACAROON`: /home/bob/.boltz/macaroons/admin.macaroon or Base64/Hex
- `LND_REST_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex - `BOLTZ_CLIENT_CERT`: /home/bob/.boltz/tls.cert or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: lnbits
or: ### ZBD
- `LND_REST_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn` For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
## LND (gRPC) - `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet**
- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/
- `ZBD_API_KEY`: ZBDApiKey
**Required env vars** ### Phoenixd
- `LNBITS_BACKEND_WALLET_CLASS`: `LndWallet` For the invoice to work you must have a publicly accessible URL in your LNbits. You can get a phoenixd API key from the install
- `LND_GRPC_ENDPOINT`: `ip_address` ~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
- `LND_GRPC_PORT`: `port`
- `LND_GRPC_CERT`: `/file/path/tls.cert`
- `LND_GRPC_MACAROON`: `/file/path/admin.macaroon` or Base64/Hex
You can also use an AES-encrypted macaroon instead: - `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
- `LND_GRPC_MACAROON_ENCRYPTED`: `eNcRyPtEdMaCaRoOn` ### Breez SDK
To encrypt your macaroon: A Greenlight invite code or Greenlight partner certificate/key can be used to register a new node with Greenlight. If the Greenlight node already exists, neither are required.
```bash - `LNBITS_BACKEND_WALLET_CLASS`: **BreezSdkWallet**
uv run lnbits-cli encrypt macaroon - `BREEZ_API_KEY`: ...
``` - `BREEZ_GREENLIGHT_SEED`: ...
- `BREEZ_GREENLIGHT_INVITE_CODE`: ...
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
## LNbits ### Cliche Wallet
**Required env vars** - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
- `LNBITS_BACKEND_WALLET_CLASS`: `LNbitsWallet` ### Nostr Wallet Connect (NWC)
- `LNBITS_ENDPOINT`: for example `https://lnbits.com`
- `LNBITS_KEY`: `lnbitsAdminKey`
## LNPay To use NWC as funding source in LNbits you'll need a pairing URL (also known as pairing secret) from a NWC service provider. You can find a list of providers [here](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets).
For the invoice listener to work you must have a publicly accessible URL in your LNbits and set up [LNPay webhooks](https://dashboard.lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the event **Wallet Receive** and no secret. Example: [https://mylnbits/wallet/webhook](`https://mylnbits/wallet/webhook). You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
**Required env vars** - `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
- `LNBITS_BACKEND_WALLET_CLASS`: `LNPayWallet`
- `LNPAY_API_ENDPOINT`: `https://api.lnpay.co/v1/`
- `LNPAY_API_KEY`: `sak_apiKey`
- `LNPAY_WALLET_KEY`: `waka_apiKey`
## OpenNode
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `OpenNodeWallet`
- `OPENNODE_API_ENDPOINT`: `https://api.opennode.com/`
- `OPENNODE_KEY`: `opennodeAdminApiKey`
## Blink
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
You can generate a Blink API key at [https://dashboard.blink.sv](https://dashboard.blink.sv). More info: [https://dev.blink.sv/api/auth#create-an-api-key](https://dev.blink.sv/api/auth#create-an-api-key)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BlinkWallet`
- `BLINK_API_ENDPOINT`: `https://api.blink.sv/graphql`
- `BLINK_WS_ENDPOINT`: `wss://ws.blink.sv/graphql`
- `BLINK_TOKEN`: `BlinkToken`
## Alby
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
Generate an Alby access token here: [https://getalby.com/developer/access_tokens/new](https://getalby.com/developer/access_tokens/new)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `AlbyWallet`
- `ALBY_API_ENDPOINT`: `https://api.getalby.com/`
- `ALBY_ACCESS_TOKEN`: `AlbyAccessToken`
## Boltz
This connects to a running [boltz-client](https://docs.boltz.exchange/v/boltz-client) and handles Lightning payments through submarine swaps on the Liquid network.
You can run the daemon in standalone mode via `standalone = True` in the config or `boltzd --standalone`. Create a Liquid wallet with:
```bash
boltzcli wallet create lnbits lbtc
```
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BoltzWallet`
- `BOLTZ_CLIENT_ENDPOINT`: `127.0.0.1:9002`
- `BOLTZ_CLIENT_MACAROON`: `/home/bob/.boltz/macaroons/admin.macaroon` or Base64/Hex
- `BOLTZ_CLIENT_CERT`: `/home/bob/.boltz/tls.cert` or Base64/Hex
- `BOLTZ_CLIENT_WALLET`: `lnbits`
## ZBD
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook configuration required.
Generate a ZBD API key here: [https://zbd.dev/docs/dashboard/projects/api](https://zbd.dev/docs/dashboard/projects/api)
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `ZBDWallet`
- `ZBD_API_ENDPOINT`: `https://api.zebedee.io/v0/`
- `ZBD_API_KEY`: `ZBDApiKey`
## Phoenixd
For the invoice to work you must have a publicly accessible URL in your LNbits.
You can get a phoenixd API key from `~/.phoenix/phoenix.conf`. See the phoenixd documentation for details.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `PhoenixdWallet`
- `PHOENIXD_API_ENDPOINT`: `http://localhost:9740/`
- `PHOENIXD_API_PASSWORD`: `PhoenixdApiPassword`
## Eclair (ACINQ)
<a id="eclair-acinq"></a>
Connect to an existing Eclair node so your backend handles invoices and payments.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `EclairWallet`
- `ECLAIR_URL`: `http://127.0.0.1:8283`
- `ECLAIR_PASSWORD`: `eclairpw`
## Fake Wallet
<a id="fake-wallet"></a>
A testing-only backend that mints accounting units inside LNbits accounting (no real sats).
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `FakeWallet`
- `FAKE_SECRET`: `ToTheMoon1`
- `FAKE_UNIT`: `sats`
## LN.tips
<a id="lntips"></a>
As the initinal LN.tips bot is no longer active the code still exists and is widly used. Connect one of custodial services as your backend to create and pay Lightning invoices through their API or selfhost this service and run it as funding source.
Resources: https://github.com/massmux/SatsMobiBot
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `LNTipsWallet`
- `LNTIPS_API_ENDPOINT`: `https://ln.tips`
- `LNTIPS_API_KEY`: `LNTIPS_ADMIN_KEY`
## Breez SDK
A Greenlight invite code or Greenlight partner certificate/key can register a new node with Greenlight. If the Greenlight node already exists, neither is required.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezSdkWallet`
- `BREEZ_API_KEY`: `...`
- `BREEZ_GREENLIGHT_SEED`: `...`
- `BREEZ_GREENLIGHT_INVITE_CODE`: `...`
- `BREEZ_GREENLIGHT_DEVICE_KEY`: `/path/to/breezsdk/device.pem` or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: `/path/to/breezsdk/device.crt` or Base64/Hex
## Breez Liquid SDK
This uses the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage Lightning payments via submarine swaps on the Liquid network. Provide a mnemonic seed phrase (for example, generate one with a Liquid wallet like [Blockstream Green](https://blockstream.com/green/)) and set it in the environment or admin UI.
**Required env vars**
- `LNBITS_BACKEND_WALLET_CLASS`: `BreezLiquidSdkWallet`
- `BREEZ_LIQUID_SEED`: `...`
Fees apply for each submarine swap. You may need to increase the reserve fee under **Settings → Funding** or via:
- `LNBITS_RESERVE_FEE_MIN`: `...`
- `LNBITS_RESERVE_FEE_PERCENT`: `...`
## Cliche Wallet
**Required env vars**
- `CLICHE_ENDPOINT`: `ws://127.0.0.1:12000`
## Nostr Wallet Connect (NWC)
To use NWC as a funding source you need a pairing URL (pairing secret) from an NWC provider. See providers here:
[https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets)
Configure in the admin UI or via env vars:
- `LNBITS_BACKEND_WALLET_CLASS`: `NWCWallet`
- `NWC_PAIRING_URL`: `nostr+walletconnect://...your...pairing...secret...`
<a id="strike"></a>
## Strike (alpha)
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
Get more info here [https://docs.strike.me/strike-oauth-connect/](https://docs.strike.me/strike-oauth-connect/)
**Integration endpoints**
- `STRIKE_API_ENDPOINT`: `https://api.strike.me/v1`
- `STRIKE_API_KEY`: `YOUR_STRIKE_API_KEY`
---
## Additional Guides
- **[Admin UI](./admin_ui.md)** — Manage server settings via a clean UI (avoid editing `.env` by hand).
- **[User Roles](./user_roles.md)** — Quick Overview of existing Roles in LNBits.
- **[Funding sources](./funding-sources-table.md)** — Whats available and how to enable/configure each.
## Powered by LNbits
LNbits empowers everyone with modular, open-source tools for building Bitcoin-based systems — fast, free, and extendable.
If you like this project, [send some tip love](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) or visit our [Shop](https://shop.lnbits.de)
[![LNbits Shop](https://demo.lnbits.com/static/images/bitcoin-shop-banner.png)](https://shop.lnbits.com/)
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)
[![Read LNbits News](https://img.shields.io/badge/Read-LNbits%20News-F97316?logo=rss&logoColor=white&labelColor=C2410C)](https://news.lnbits.com/)
[![Explore LNbits Extensions](https://img.shields.io/badge/Explore-LNbits%20Extensions-10B981?logo=puzzle-piece&logoColor=white&labelColor=065F46)](https://extensions.lnbits.com/)
Generated
+61 -76
View File
@@ -1,39 +1,15 @@
{ {
"nodes": { "nodes": {
"build-system-pkgs": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
],
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1755484659,
"narHash": "sha256-2FfbqsaHVQd12XFFUAinIMAuGO3853LONmva1gT3vKw=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "9778e87c2361810ff15e287ca5895c9da4a0e900",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1710146030,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -42,49 +18,71 @@
"type": "github" "type": "github"
} }
}, },
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1751274312, "lastModified": 1735563628,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", "narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"owner": "NixOS", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", "rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "nixos",
"ref": "nixos-24.11", "ref": "nixos-24.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"pyproject-nix": { "poetry2nix": {
"inputs": { "inputs": {
"flake-utils": "flake-utils",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
] ],
"systems": "systems_2",
"treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1754923840, "lastModified": 1724134185,
"narHash": "sha256-QSKpYg+Ts9HYF155ltlj40iBex39c05cpOF8gjoE2EM=", "narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"owner": "pyproject-nix", "owner": "nix-community",
"repo": "pyproject.nix", "repo": "poetry2nix",
"rev": "023cd4be230eacae52635be09eef100c37ef78da", "rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "pyproject-nix", "owner": "nix-community",
"repo": "pyproject.nix", "repo": "poetry2nix",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"build-system-pkgs": "build-system-pkgs",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"pyproject-nix": "pyproject-nix", "poetry2nix": "poetry2nix"
"uv2nix": "uv2nix_2"
} }
}, },
"systems": { "systems": {
@@ -102,51 +100,38 @@
"type": "github" "type": "github"
} }
}, },
"uv2nix": { "systems_2": {
"inputs": {
"nixpkgs": [
"build-system-pkgs",
"nixpkgs"
],
"pyproject-nix": [
"build-system-pkgs",
"pyproject-nix"
]
},
"locked": { "locked": {
"lastModified": 1755210905, "lastModified": 1681028828,
"narHash": "sha256-WnoFEk79ysjL85TNP7bvImzhxvQw9B6uNtnLd4oJntw=", "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "pyproject-nix", "owner": "nix-systems",
"repo": "uv2nix", "repo": "default",
"rev": "87bcba013ef304bbfd67c8e8a257aee634ed5a4c", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "pyproject-nix", "id": "systems",
"repo": "uv2nix", "type": "indirect"
"type": "github"
} }
}, },
"uv2nix_2": { "treefmt-nix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"poetry2nix",
"nixpkgs" "nixpkgs"
],
"pyproject-nix": [
"pyproject-nix"
] ]
}, },
"locked": { "locked": {
"lastModified": 1755485731, "lastModified": 1719749022,
"narHash": "sha256-k8kxwVs8Oze6q/jAaRa3RvZbb50I/K0b5uptlsh0HXI=", "narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "pyproject-nix", "owner": "numtide",
"repo": "uv2nix", "repo": "treefmt-nix",
"rev": "bebbd80bf56110fcd20b425589814af28f1939eb", "rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "pyproject-nix", "owner": "numtide",
"repo": "uv2nix", "repo": "treefmt-nix",
"type": "github" "type": "github"
} }
} }
+55 -136
View File
@@ -1,151 +1,70 @@
{ {
description = "LNbits, free and open-source Lightning wallet and accounts system (uv2nix)"; description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils"; poetry2nix = {
url = "github:nix-community/poetry2nix";
pyproject-nix.url = "github:pyproject-nix/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs";
uv2nix.url = "github:pyproject-nix/uv2nix"; };
build-system-pkgs.url = "github:pyproject-nix/build-system-pkgs";
pyproject-nix.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.nixpkgs.follows = "nixpkgs";
build-system-pkgs.inputs.nixpkgs.follows = "nixpkgs";
uv2nix.inputs.pyproject-nix.follows = "pyproject-nix";
build-system-pkgs.inputs.pyproject-nix.follows = "pyproject-nix";
}; };
outputs = { self, nixpkgs, poetry2nix }@inputs:
outputs = { self, nixpkgs, flake-utils, uv2nix, pyproject-nix, build-system-pkgs, ... }: let
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ] supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
(system: forSystems = systems: f:
let nixpkgs.lib.genAttrs systems
pkgs = import nixpkgs { inherit system; }; (system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlays.default self.overlays.default ]; }));
lib = pkgs.lib; forAllSystems = forSystems supportedSystems;
projectName = "lnbits";
python = pkgs.python312; in
{
# Read uv.lock / pyproject via uv2nix overlays = {
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; default = final: prev: {
${projectName} = self.packages.${prev.stdenv.hostPlatform.system}.${projectName};
# Prefer wheels when available };
uvLockedOverlay = workspace.mkPyprojectOverlay { sourcePreference = "wheel"; }; };
packages = forAllSystems (system: pkgs: {
# Helper for extending lists safely (works if a is null) default = self.packages.${system}.${projectName};
plus = a: b: lib.unique (((if a == null then [] else a)) ++ b); ${projectName} = pkgs.poetry2nix.mkPoetryApplication {
projectDir = ./.;
# Extra build inputs for troublesome sdists meta.rev = self.dirtyRev or self.rev;
myOverrides = (final: prev: { meta.mainProgram = projectName;
# embit needs setuptools at build time overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
embit = prev.embit.overrideAttrs (old: { coincurve = prev.coincurve.override { preferWheel = true; };
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ]; protobuf = prev.protobuf.override { preferWheel = true; };
}); ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
# http-ece (pywebpush dep) needs setuptools
"http-ece" = prev."http-ece".overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# pyqrcode needs setuptools
pyqrcode = prev.pyqrcode.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ];
});
# tlv8 needs setuptools
tlv8 = prev.tlv8.overrideAttrs (old: { tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools ]; nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
});
# secp256k1 Python binding:
# - setuptools, pkg-config
# - cffi + pycparser
# - system libsecp256k1 for headers/libs
secp256k1 = prev.secp256k1.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [
prev.setuptools prev.setuptools
pkgs.pkg-config
prev.cffi
prev.pycparser
]; ];
buildInputs = plus (old.buildInputs or []) [ pkgs.secp256k1 ];
propagatedBuildInputs = plus (old.propagatedBuildInputs or []) [ prev.cffi prev.pycparser ];
env = (old.env or { }) // { PKG_CONFIG = "${pkgs.pkg-config}/bin/pkg-config"; };
}); });
# pynostr uses setuptools-scm for versioning
pynostr = prev.pynostr.overrideAttrs (old: { pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = plus (old.nativeBuildInputs or []) [ prev.setuptools-scm ]; nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools-scm
];
}); });
}); });
};
# Compose Python package set honoring uv.lock });
pythonSet = nixosModules = {
(pkgs.callPackage pyproject-nix.build.packages { inherit python; }) default = { pkgs, lib, config, ... }: {
.overrideScope (lib.composeManyExtensions [ imports = [ "${./nix/modules/${projectName}-service.nix}" ];
build-system-pkgs.overlays.default nixpkgs.overlays = [ self.overlays.default ];
uvLockedOverlay };
myOverrides };
]); checks = forAllSystems (system: pkgs:
let
projectName = "lnbits"; vmTests = import ./nix/tests {
makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest;
# Build a venv from the locked spec (this installs the resolved wheels) inherit inputs pkgs;
runtimeVenv = pythonSet.mkVirtualEnv "${projectName}-env" workspace.deps.default;
# Wrapper so `nix run` behaves like `uv run` (use local source tree for templates/static/extensions)
lnbitsApp = pkgs.writeShellApplication {
name = "lnbits";
text = ''
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export REQUESTS_CA_BUNDLE=$SSL_CERT_FILE
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits "$@"
'';
};
lnbitsCliApp = pkgs.writeShellApplication {
name = "lnbits-cli";
text = ''
export PYTHONPATH="$PWD:${PYTHONPATH:-}"
exec ${runtimeVenv}/bin/lnbits-cli "$@"
'';
}; };
in in
pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux.
//
{ {
# nix build → produces the venv in ./result # Other checks here...
packages.default = runtimeVenv; }
packages.${projectName} = runtimeVenv; );
};
# nix run . → launches via wrapper that imports from source tree
apps.default = { type = "app"; program = "${lnbitsApp}/bin/lnbits"; };
apps.${projectName} = self.apps.${system}.default;
apps."${projectName}-cli" = { type = "app"; program = "${lnbitsCliApp}/bin/lnbits-cli"; };
# dev shell with locked deps + tools
devShells.default = pkgs.mkShell {
packages = [
runtimeVenv
pkgs.uv
pkgs.ruff
pkgs.black
pkgs.mypy
pkgs.pre-commit
pkgs.openapi-generator-cli
];
};
overlays.default = final: prev: {
${projectName} = self.packages.${final.stdenv.hostPlatform.system}.${projectName};
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
};
# System-specific nixos modules to avoid circular dependency
nixosModules.default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/lnbits-service.nix}" ];
nixpkgs.overlays = [ self.overlays.${system}.default ];
};
checks =
import ./nix/tests { inherit pkgs; flake = self; };
});
} }
+47 -68
View File
@@ -1,75 +1,54 @@
#!/usr/bin/env bash #!/bin/bash
set -euo pipefail
# --- Config you might tweak --- # Check install has not already run
REPO_URL="https://github.com/lnbits/lnbits.git" if [ ! -d lnbits/data ]; then
BRANCH="main"
APP_DIR="${PWD}/lnbits"
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-5000}"
ADMIN_UI="${LNBITS_ADMIN_UI:-true}"
# -------------------------------
export DEBIAN_FRONTEND=noninteractive # Update package list and install prerequisites non-interactively
sudo apt update -y
sudo apt install -y software-properties-common
# Add the deadsnakes PPA repository non-interactively
sudo add-apt-repository -y ppa:deadsnakes/ppa
# Install Python 3.9 and distutils non-interactively
sudo apt install -y python3.9 python3.9-distutils
# Ensure basic tooling # Install Poetry
if ! command -v curl >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then curl -sSL https://install.python-poetry.org | python3.9 -
sudo apt-get update -y
sudo apt-get install -y curl git # Add Poetry to PATH for the current session
export PATH="/home/$USER/.local/bin:$PATH"
if [ ! -d lnbits/wallets ]; then
# Clone the LNbits repository
git clone https://github.com/lnbits/lnbits.git
if [ $? -ne 0 ]; then
echo "Failed to clone the repository ... FAIL"
exit 1
fi
# Ensure we are in the lnbits directory
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi
git checkout main
# Make data folder
mkdir data
# Copy the .env.example to .env
cp .env.example .env
elif [ ! -d lnbits/wallets ]; then
# cd into lnbits
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
fi fi
# System build deps and secp headers # Install the dependencies using Poetry
if command -v apt-get >/dev/null 2>&1; then poetry env use python3.9
sudo apt-get update -y poetry install --only main
sudo apt-get install -y \
pkg-config \
build-essential \
libsecp256k1-dev \
automake \
autoconf \
libtool \
m4
fi
# Install uv (if missing) # Set environment variables for LNbits
if ! command -v uv >/dev/null 2>&1; then export LNBITS_ADMIN_UI=true
curl -LsSf https://astral.sh/uv/install.sh | sh export HOST=0.0.0.0
export PATH="$HOME/.local/bin:$PATH"
fi
# Ensure PATH for current session
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
export PATH="$HOME/.local/bin:$PATH"
fi
# Clone or reuse repo # Run LNbits
if [[ ! -d "$APP_DIR/.git" ]]; then poetry run lnbits
git clone "$REPO_URL" "$APP_DIR"
fi
cd "$APP_DIR"
git fetch --all --prune
git checkout "$BRANCH"
git pull --ff-only || true
# First-run setup
mkdir -p data
[[ -f .env ]] || cp .env.example .env || true
# Prefer system libsecp256k1 (avoid autotools path)
export SECP_BUNDLED=0
# Sync dependencies with Python 3.12
uv sync --python 3.12 --all-extras --no-dev
# Environment
export LNBITS_ADMIN_UI="$ADMIN_UI"
export HOST="$HOST"
export PORT="$PORT"
# Open firewall (optional)
if command -v ufw >/dev/null 2>&1; then
sudo ufw allow "$PORT"/tcp || true
fi
# Run LNbits with Python 3.12 via uv
exec uv run --python 3.12 lnbits
+8 -7
View File
@@ -1,6 +1,5 @@
from .core.services import create_invoice, pay_invoice from .core.services import create_invoice, pay_invoice
from .decorators import ( from .decorators import (
check_account_exists,
check_admin, check_admin,
check_super_user, check_super_user,
check_user_exists, check_user_exists,
@@ -10,14 +9,16 @@ from .decorators import (
from .exceptions import InvoiceError, PaymentError from .exceptions import InvoiceError, PaymentError
__all__ = [ __all__ = [
"InvoiceError", # decorators
"PaymentError", "require_admin_key",
"check_account_exists", "require_invoice_key",
"check_admin", "check_admin",
"check_super_user", "check_super_user",
"check_user_exists", "check_user_exists",
"create_invoice", # services
"pay_invoice", "pay_invoice",
"require_admin_key", "create_invoice",
"require_invoice_key", # exceptions
"PaymentError",
"InvoiceError",
] ]
+18 -17
View File
@@ -4,10 +4,9 @@ import importlib
import os import os
import shutil import shutil
import sys import sys
import time
from collections.abc import Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Callable, Optional
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -27,8 +26,7 @@ from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification from lnbits.core.services.notifications import enqueue_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import ( from lnbits.core.tasks import (
audit_queue, audit_queue,
collect_exchange_rates_data, collect_exchange_rates_data,
@@ -67,13 +65,17 @@ from .middleware import (
add_ip_block_middleware, add_ip_block_middleware,
add_ratelimit_middleware, add_ratelimit_middleware,
) )
from .tasks import internal_invoice_listener, invoice_listener, run_interval from .requestvars import g
from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
)
async def startup(app: FastAPI): async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True settings.lnbits_running = True
# wait till migration is done # wait till migration is done
await migrate_databases() await migrate_databases()
@@ -104,7 +106,7 @@ async def startup(app: FastAPI):
# initialize tasks # initialize tasks
register_async_tasks() register_async_tasks()
enqueue_admin_notification( enqueue_notification(
NotificationType.server_start_stop, NotificationType.server_start_stop,
{ {
"message": "LNbits server started.", "message": "LNbits server started.",
@@ -112,13 +114,10 @@ async def startup(app: FastAPI):
}, },
) )
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
async def shutdown(): async def shutdown():
logger.warning("LNbits shutting down...") logger.warning("LNbits shutting down...")
enqueue_admin_notification( enqueue_notification(
NotificationType.server_start_stop, NotificationType.server_start_stop,
{ {
"message": "LNbits server shutting down...", "message": "LNbits server shutting down...",
@@ -172,6 +171,8 @@ def create_app() -> FastAPI:
name="library", name="library",
) )
g().base_url = f"http://{settings.host}:{settings.port}"
app.add_middleware( app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
) )
@@ -279,8 +280,8 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})") logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list( # noqa: C901 async def build_all_installed_extensions_list(
include_deactivated: bool | None = True, include_deactivated: Optional[bool] = True,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
""" """
Returns a list of all the installed extensions plus the extensions that Returns a list of all the installed extensions plus the extensions that
@@ -453,7 +454,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI) -> None: async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in await get_valid_extensions(False): for ext in await get_valid_extensions(False):
try: try:
@@ -463,12 +464,12 @@ async def check_and_register_extensions(app: FastAPI) -> None:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}") logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks() -> None: def register_async_tasks():
create_permanent_task(wait_for_audit_data) create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages) create_permanent_task(wait_notification_messages)
create_permanent_task(run_interval(30 * 60, check_pending_payments)) create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener) create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener) create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever) create_permanent_task(cache.invalidate_forever)
+40 -112
View File
@@ -3,8 +3,8 @@ import importlib
import sys import sys
import time import time
from functools import wraps from functools import wraps
from getpass import getpass
from pathlib import Path from pathlib import Path
from typing import Optional
from uuid import uuid4 from uuid import uuid4
import click import click
@@ -40,9 +40,7 @@ from lnbits.core.views.extension_api import (
api_uninstall_extension, api_uninstall_extension,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
from lnbits.wallets.base import Wallet from lnbits.wallets.base import Wallet
from lnbits.wallets.macaroon import load_macaroon
def coro(f): def coro(f):
@@ -81,21 +79,7 @@ def extensions():
""" """
@lnbits_cli.group() def get_super_user() -> Optional[str]:
def encrypt():
"""
Encryption commands
"""
@lnbits_cli.group()
def decrypt():
"""
Decryption commands
"""
def get_super_user() -> str | None:
"""Get the superuser""" """Get the superuser"""
superuser_file = Path(settings.lnbits_data_folder, ".super_user") superuser_file = Path(settings.lnbits_data_folder, ".super_user")
if not superuser_file.exists() or not superuser_file.is_file(): if not superuser_file.exists() or not superuser_file.is_file():
@@ -154,7 +138,7 @@ async def db_versions():
@db.command("cleanup-wallets") @db.command("cleanup-wallets")
@click.argument("days", type=int, required=False) @click.argument("days", type=int, required=False)
@coro @coro
async def database_cleanup_wallets(days: int | None = None): async def database_cleanup_wallets(days: Optional[int] = None):
"""Delete all wallets that never had any transaction""" """Delete all wallets that never had any transaction"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days delta = days or settings.cleanup_wallets_days
@@ -211,10 +195,10 @@ async def database_revert_payment(checking_id: str):
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.") @click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
@coro @coro
async def check_invalid_payments( async def check_invalid_payments(
days: int | None = None, days: Optional[int] = None,
limit: int | None = None, limit: Optional[int] = None,
wallet: str | None = None, wallet: Optional[str] = None,
verbose: bool | None = False, verbose: Optional[bool] = False,
): ):
"""Check payments that are settled in the DB but pending on the Funding Source""" """Check payments that are settled in the DB but pending on the Funding Source"""
await check_admin_settings() await check_admin_settings()
@@ -302,7 +286,7 @@ async def create_user(username: str, password: str):
@users.command("cleanup-accounts") @users.command("cleanup-accounts")
@click.argument("days", type=int, required=False) @click.argument("days", type=int, required=False)
@coro @coro
async def database_cleanup_accounts(days: int | None = None): async def database_cleanup_accounts(days: Optional[int] = None):
"""Delete all accounts that have no wallets""" """Delete all accounts that have no wallets"""
async with core_db.connect() as conn: async with core_db.connect() as conn:
delta = days or settings.cleanup_wallets_days delta = days or settings.cleanup_wallets_days
@@ -319,8 +303,9 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list(): for ext in await build_all_installed_extensions_list():
if not ext.meta or not ext.meta.installed_release: assert (
raise ValueError(f"Extension {ext.id} has no installed_release") ext.meta and ext.meta.installed_release
), f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})") click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@@ -351,13 +336,13 @@ async def extensions_list():
help="Admin user ID (must have permissions to install extensions).", help="Admin user ID (must have permissions to install extensions).",
) )
@coro @coro
async def extensions_update( # noqa: C901 async def extensions_update(
extension: str | None = None, extension: Optional[str] = None,
all_extensions: bool | None = False, all_extensions: Optional[bool] = False,
repo_index: str | None = None, repo_index: Optional[str] = None,
source_repo: str | None = None, source_repo: Optional[str] = None,
url: str | None = None, url: Optional[str] = None,
admin_user: str | None = None, admin_user: Optional[str] = None,
): ):
""" """
Update extension to the latest version. Update extension to the latest version.
@@ -442,10 +427,10 @@ async def extensions_update( # noqa: C901
@coro @coro
async def extensions_install( async def extensions_install(
extension: str, extension: str,
repo_index: str | None = None, repo_index: Optional[str] = None,
source_repo: str | None = None, source_repo: Optional[str] = None,
url: str | None = None, url: Optional[str] = None,
admin_user: str | None = None, admin_user: Optional[str] = None,
): ):
"""Install a extension""" """Install a extension"""
click.echo(f"Installing {extension}... {repo_index}") click.echo(f"Installing {extension}... {repo_index}")
@@ -472,7 +457,7 @@ async def extensions_install(
) )
@coro @coro
async def extensions_uninstall( async def extensions_uninstall(
extension: str, url: str | None = None, admin_user: str | None = None extension: str, url: Optional[str] = None, admin_user: Optional[str] = None
): ):
"""Uninstall a extension""" """Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...") click.echo(f"Uninstalling '{extension}'...")
@@ -494,62 +479,6 @@ async def extensions_uninstall(
return False, str(ex) return False, str(ex)
@encrypt.command("macaroon")
def encrypt_macaroon():
"""Encrypts a macaroon (LND wallets)"""
_macaroon = getpass("Enter macaroon: ")
try:
macaroon = load_macaroon(_macaroon)
except Exception as ex:
click.echo(f"Error loading macaroon: {ex}")
return
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted_macaroon = aes.encrypt(bytes.fromhex(macaroon))
except Exception as ex:
click.echo(f"Error encrypting macaroon: {ex}")
return
click.echo("Encrypted macaroon: ")
click.echo(encrypted_macaroon)
@encrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to encrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64encode."
)
def encrypt_aes(payload: str, urlsafe: bool = False):
"""AES encrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
encrypted = aes.encrypt(payload.encode(), urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error encrypting payload: {ex}")
return
click.echo("Encrypted payload: ")
click.echo(encrypted)
@decrypt.command("aes")
@click.option("-p", "--payload", required=True, help="Payload to decrypt.")
@click.option(
"-u", "--urlsafe", is_flag=True, required=False, help="Urlsafe b64decode."
)
def decrypt_aes(payload: str, urlsafe: bool = False):
"""AES decrypts a payload"""
key = getpass("Enter encryption key: ")
aes = AESCipher(key.encode())
try:
decrypted = aes.decrypt(payload, urlsafe=urlsafe)
except Exception as ex:
click.echo(f"Error decrypting payload: {ex}")
return
click.echo("Decrypted payload: ")
click.echo(decrypted)
def main(): def main():
"""main function""" """main function"""
lnbits_cli() lnbits_cli()
@@ -561,10 +490,10 @@ if __name__ == "__main__":
async def install_extension( async def install_extension(
extension: str, extension: str,
repo_index: str | None = None, repo_index: Optional[str] = None,
source_repo: str | None = None, source_repo: Optional[str] = None,
url: str | None = None, url: Optional[str] = None,
admin_user: str | None = None, admin_user: Optional[str] = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
try: try:
release = await _select_release(extension, repo_index, source_repo) release = await _select_release(extension, repo_index, source_repo)
@@ -590,10 +519,10 @@ async def install_extension(
async def update_extension( async def update_extension(
extension: str, extension: str,
repo_index: str | None = None, repo_index: Optional[str] = None,
source_repo: str | None = None, source_repo: Optional[str] = None,
url: str | None = None, url: Optional[str] = None,
admin_user: str | None = None, admin_user: Optional[str] = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
try: try:
click.echo(f"Updating '{extension}' extension.") click.echo(f"Updating '{extension}' extension.")
@@ -606,10 +535,9 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.") click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
if not installed_ext.meta or not installed_ext.meta.installed_release: assert (
raise ValueError( installed_ext.meta and installed_ext.meta.installed_release
"Cannot find previously installed release. Please uninstall first." ), "Cannot find previously installed release. Please uninstall first."
)
release = await _select_release(extension, repo_index, source_repo) release = await _select_release(extension, repo_index, source_repo)
if not release: if not release:
@@ -643,9 +571,9 @@ async def update_extension(
async def _select_release( async def _select_release(
extension: str, extension: str,
repo_index: str | None = None, repo_index: Optional[str] = None,
source_repo: str | None = None, source_repo: Optional[str] = None,
) -> ExtensionRelease | None: ) -> Optional[ExtensionRelease]:
all_releases = await InstallableExtension.get_extension_releases(extension) all_releases = await InstallableExtension.get_extension_releases(extension)
if len(all_releases) == 0: if len(all_releases) == 0:
click.echo(f"No repository found for extension '{extension}'.") click.echo(f"No repository found for extension '{extension}'.")
@@ -705,7 +633,7 @@ def _get_latest_release_per_repo(all_releases):
async def _call_install_extension( async def _call_install_extension(
data: CreateExtension, url: str | None, user_id: str | None = None data: CreateExtension, url: Optional[str], user_id: Optional[str] = None
): ):
if url: if url:
user_id = user_id or get_super_user() user_id = user_id or get_super_user()
@@ -719,7 +647,7 @@ async def _call_install_extension(
async def _call_uninstall_extension( async def _call_uninstall_extension(
extension: str, url: str | None, user_id: str | None = None extension: str, url: Optional[str], user_id: Optional[str] = None
): ):
if url: if url:
user_id = user_id or get_super_user() user_id = user_id or get_super_user()
@@ -755,7 +683,7 @@ async def _can_run_operation(url) -> bool:
return True return True
async def _is_lnbits_started(url: str | None): async def _is_lnbits_started(url: Optional[str]):
try: try:
url = url or f"http://{settings.host}:{settings.port}/api/v1/health" url = url or f"http://{settings.host}:{settings.port}/api/v1/health"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
-10
View File
@@ -3,17 +3,12 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db from .db import core_app_extra, db
from .views.admin_api import admin_router from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router from .views.payment_api import payment_router
from .views.tinyurl_api import tinyurl_router from .views.tinyurl_api import tinyurl_router
@@ -33,21 +28,16 @@ def init_core_routers(app: FastAPI):
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(node_router) app.include_router(node_router)
app.include_router(extension_router) app.include_router(extension_router)
app.include_router(extension_builder_router)
app.include_router(super_node_router) app.include_router(super_node_router)
app.include_router(public_node_router) app.include_router(public_node_router)
app.include_router(payment_router) app.include_router(payment_router)
app.include_router(wallet_router) app.include_router(wallet_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(websocket_router) app.include_router(websocket_router)
app.include_router(callback_router)
app.include_router(tinyurl_router) app.include_router(tinyurl_router)
app.include_router(webpush_router) app.include_router(webpush_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(audit_router) app.include_router(audit_router)
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]
+63 -56
View File
@@ -43,7 +43,6 @@ from .settings import (
delete_admin_settings, delete_admin_settings,
get_admin_settings, get_admin_settings,
get_super_settings, get_super_settings,
reset_core_settings,
update_admin_settings, update_admin_settings,
update_super_user, update_super_user,
) )
@@ -85,78 +84,86 @@ from .webpush import (
) )
__all__ = [ __all__ = [
"DateTrunc", # audit
"check_internal",
"create_account",
"create_admin_settings",
"create_audit_entry", "create_audit_entry",
"create_installed_extension", # db_versions
"create_payment",
"create_tinyurl",
"create_user_extension",
"create_wallet",
"create_webpush_subscription",
"delete_account",
"delete_accounts_no_wallets",
"delete_admin_settings",
"delete_dbversion",
"delete_expired_invoices",
"delete_installed_extension",
"delete_tinyurl",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"delete_wallet_payment",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"drop_extension_db",
"force_delete_wallet",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_admin_settings",
"get_db_version", "get_db_version",
"get_db_versions", "get_db_versions",
"update_migration_version",
"delete_dbversion",
# extensions
"create_installed_extension",
"create_user_extension",
"delete_installed_extension",
"drop_extension_db",
"get_installed_extension", "get_installed_extension",
"get_installed_extensions", "get_installed_extensions",
"get_user_active_extensions_ids",
"get_user_extension",
"update_installed_extension",
"update_installed_extension_state",
"update_user_extension",
"get_user_extensions",
# payments
"DateTrunc",
"check_internal",
"create_payment",
"delete_expired_invoices",
"delete_wallet_payment",
"get_latest_payments_by_extension", "get_latest_payments_by_extension",
"get_payment", "get_payment",
"get_payments", "get_payments",
"get_payments_history", "get_payments_history",
"get_payments_paginated", "get_payments_paginated",
"get_standalone_payment", "get_standalone_payment",
"get_super_settings",
"get_tinyurl",
"get_tinyurl_by_url",
"get_total_balance",
"get_user",
"get_user_access_control_lists",
"get_user_active_extensions_ids",
"get_user_extension",
"get_user_extensions",
"get_user_from_account",
"get_wallet",
"get_wallet_for_key",
"get_wallet_payment", "get_wallet_payment",
"get_wallets",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
"is_internal_status_success", "is_internal_status_success",
"mark_webhook_sent", "mark_webhook_sent",
"remove_deleted_wallets",
"reset_core_settings",
"update_account",
"update_admin_settings",
"update_installed_extension",
"update_installed_extension_state",
"update_migration_version",
"update_payment", "update_payment",
"update_payment_checking_id", "update_payment_checking_id",
"update_payment_extra", "update_payment_extra",
# settings
"create_admin_settings",
"delete_admin_settings",
"get_admin_settings",
"get_super_settings",
"update_admin_settings",
"update_super_user", "update_super_user",
"update_user_extension", # tinyurl
"create_tinyurl",
"delete_tinyurl",
"get_tinyurl",
"get_tinyurl_by_url",
# users
"create_account",
"delete_account",
"delete_accounts_no_wallets",
"get_account",
"get_account_by_email",
"get_account_by_pubkey",
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_user",
"get_user_from_account",
"get_user_access_control_lists",
"update_account",
# wallets
"create_wallet",
"delete_unused_wallets",
"delete_wallet",
"delete_wallet_by_id",
"force_delete_wallet",
"get_total_balance",
"get_wallet",
"get_wallet_for_key",
"get_wallets",
"remove_deleted_wallets",
"update_wallet", "update_wallet",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
] ]
-108
View File
@@ -1,108 +0,0 @@
from lnbits.core.db import db
from lnbits.core.models.assets import Asset, AssetFilters, AssetInfo
from lnbits.db import Connection, Filters, Page
async def create_asset(
entry: Asset,
conn: Connection | None = None,
) -> None:
await (conn or db).insert("assets", entry)
async def get_user_asset_info(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=AssetInfo,
)
async def get_asset_info(
asset_id: str, conn: Connection | None = None
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def get_user_asset(
user_id: str,
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
model=Asset,
)
async def get_public_asset(
asset_id: str,
conn: Connection | None = None,
) -> Asset | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=Asset,
)
async def get_public_asset_info(
asset_id: str,
conn: Connection | None = None,
) -> AssetInfo | None:
return await (conn or db).fetchone(
query="SELECT * from assets WHERE id = :asset_id AND is_public = true",
values={"asset_id": asset_id},
model=AssetInfo,
)
async def update_user_asset_info(
asset: AssetInfo,
) -> AssetInfo:
await db.update("assets", asset)
return asset
async def delete_user_asset(
user_id: str, asset_id: str, conn: Connection | None = None
) -> None:
await (conn or db).execute(
query="DELETE FROM assets WHERE id = :asset_id AND user_id = :user_id",
values={"asset_id": asset_id, "user_id": user_id},
)
async def get_user_assets(
user_id: str,
filters: Filters[AssetFilters] | None = None,
conn: Connection | None = None,
) -> Page[AssetInfo]:
filters = filters or Filters()
filters.sortby = filters.sortby or "created_at"
return await (conn or db).fetch_page(
query="SELECT * FROM assets",
where=["user_id = :user_id"],
values={"user_id": user_id},
filters=filters,
model=AssetInfo,
table_name="assets",
)
async def get_user_assets_count(user_id: str) -> int:
result = await db.execute(
query="SELECT COUNT(*) as count FROM assets WHERE user_id = :user_id",
values={"user_id": user_id},
)
row = result.mappings().first()
return row.get("count", 0)
+14 -19
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat from lnbits.core.models.audit import AuditCountStat
@@ -6,41 +8,39 @@ from lnbits.db import Connection, Filters, Page
async def create_audit_entry( async def create_audit_entry(
entry: AuditEntry, entry: AuditEntry,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
await (conn or db).insert("audit", entry) await (conn or db).insert("audit", entry)
async def get_audit_entries( async def get_audit_entries(
filters: Filters[AuditFilters] | None = None, filters: Optional[Filters[AuditFilters]] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Page[AuditEntry]: ) -> Page[AuditEntry]:
return await (conn or db).fetch_page( return await (conn or db).fetch_page(
"SELECT * FROM audit", "SELECT * from audit",
[], [],
{}, {},
filters=filters, filters=filters,
model=AuditEntry, model=AuditEntry,
table_name="audit",
) )
async def delete_expired_audit_entries( async def delete_expired_audit_entries(
conn: Connection | None = None, conn: Optional[Connection] = None,
): ):
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE from audit DELETE from audit
WHERE delete_at < {db.timestamp_now} WHERE delete_at < {db.timestamp_now}
""", # noqa: S608 """,
) )
async def get_count_stats( async def get_count_stats(
field: str, field: str,
filters: Filters[AuditFilters] | None = None, filters: Optional[Filters[AuditFilters]] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> list[AuditCountStat]: ) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]: if field not in ["request_method", "component", "response_code"]:
return [] return []
@@ -48,16 +48,13 @@ async def get_count_stats(
filters = Filters() filters = Filters()
clause = filters.where() clause = filters.where()
data = await (conn or db).fetchall( data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f""" query=f"""
SELECT {field} as field, count({field}) as total SELECT {field} as field, count({field}) as total
FROM audit FROM audit
{clause} {clause}
GROUP BY {field} GROUP BY {field}
ORDER BY {field} ORDER BY {field}
""", # noqa: S608 """,
values=filters.values(), values=filters.values(),
model=AuditCountStat, model=AuditCountStat,
) )
@@ -66,22 +63,20 @@ async def get_count_stats(
async def get_long_duration_stats( async def get_long_duration_stats(
filters: Filters[AuditFilters] | None = None, filters: Optional[Filters[AuditFilters]] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> list[AuditCountStat]: ) -> list[AuditCountStat]:
if not filters: if not filters:
filters = Filters() filters = Filters()
clause = filters.where() clause = filters.where()
long_duration_paths = await (conn or db).fetchall( long_duration_paths = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
query=f""" query=f"""
SELECT path as field, max(duration) as total FROM audit SELECT path as field, max(duration) as total FROM audit
{clause} {clause}
GROUP BY path GROUP BY path
ORDER BY total DESC ORDER BY total DESC
LIMIT 5 LIMIT 5
""", # noqa: S608 """,
values=filters.values(), values=filters.values(),
model=AuditCountStat, model=AuditCountStat,
) )
+6 -4
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.db import Connection from lnbits.db import Connection
@@ -5,8 +7,8 @@ from ..models import DbVersion
async def get_db_version( async def get_db_version(
ext_id: str, conn: Connection | None = None ext_id: str, conn: Optional[Connection] = None
) -> DbVersion | None: ) -> Optional[DbVersion]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id", "SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id}, {"ext_id": ext_id},
@@ -14,7 +16,7 @@ async def get_db_version(
) )
async def get_db_versions(conn: Connection | None = None) -> list[DbVersion]: async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion) return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
@@ -28,7 +30,7 @@ async def update_migration_version(conn, db_name, version):
) )
async def delete_dbversion(*, ext_id: str, conn: Connection | None = None) -> None: async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute( await (conn or db).execute(
""" """
DELETE FROM dbversions WHERE db = :ext DELETE FROM dbversions WHERE db = :ext
+20 -22
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
InstallableExtension, InstallableExtension,
@@ -8,20 +10,20 @@ from lnbits.db import Connection, Database
async def create_installed_extension( async def create_installed_extension(
ext: InstallableExtension, ext: InstallableExtension,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
await (conn or db).insert("installed_extensions", ext) await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension( async def update_installed_extension(
ext: InstallableExtension, ext: InstallableExtension,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
await (conn or db).update("installed_extensions", ext) await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state( async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Connection | None = None *, ext_id: str, active: bool, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
""" """
@@ -32,7 +34,7 @@ async def update_installed_extension_state(
async def delete_installed_extension( async def delete_installed_extension(
*, ext_id: str, conn: Connection | None = None *, ext_id: str, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
""" """
@@ -42,14 +44,13 @@ async def delete_installed_extension(
) )
async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None: async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
row: dict = await (conn or db).fetchone( row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id", "SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id}, {"id": ext_id},
) )
# Check that 'ext_id' is a valid extension id and not a malicious string # Check that 'ext_id' is a valid extension id and not a malicious string
if not row: assert row, f"Extension '{ext_id}' db version cannot be found"
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id) is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db: if is_file_based_db:
@@ -63,8 +64,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
async def get_installed_extension( async def get_installed_extension(
ext_id: str, conn: Connection | None = None ext_id: str, conn: Optional[Connection] = None
) -> InstallableExtension | None: ) -> Optional[InstallableExtension]:
extension = await (conn or db).fetchone( extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id", "SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id}, {"id": ext_id},
@@ -74,16 +75,13 @@ async def get_installed_extension(
async def get_installed_extensions( async def get_installed_extensions(
active: bool | None = None, active: Optional[bool] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
query = "SELECT * FROM installed_extensions" where = "WHERE active = :active" if active is not None else ""
if active is not None:
query += " WHERE active = :active"
values = {"active": active} if active is not None else {} values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall( all_extensions = await (conn or db).fetchall(
query, f"SELECT * FROM installed_extensions {where}",
values, values,
model=InstallableExtension, model=InstallableExtension,
) )
@@ -91,8 +89,8 @@ async def get_installed_extensions(
async def get_user_extension( async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None user_id: str, extension: str, conn: Optional[Connection] = None
) -> UserExtension | None: ) -> Optional[UserExtension]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM extensions SELECT * FROM extensions
@@ -104,7 +102,7 @@ async def get_user_extension(
async def get_user_extensions( async def get_user_extensions(
user_id: str, conn: Connection | None = None user_id: str, conn: Optional[Connection] = None
) -> list[UserExtension]: ) -> list[UserExtension]:
return await (conn or db).fetchall( return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""", """SELECT * FROM extensions WHERE "user" = :user""",
@@ -114,20 +112,20 @@ async def get_user_extensions(
async def create_user_extension( async def create_user_extension(
user_extension: UserExtension, conn: Connection | None = None user_extension: UserExtension, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).insert("extensions", user_extension) await (conn or db).insert("extensions", user_extension)
async def update_user_extension( async def update_user_extension(
user_extension: UserExtension, conn: Connection | None = None user_extension: UserExtension, conn: Optional[Connection] = None
) -> None: ) -> None:
where = """WHERE extension = :extension AND "user" = :user""" where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where) await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids( async def get_user_active_extensions_ids(
user_id: str, conn: Connection | None = None user_id: str, conn: Optional[Connection] = None
) -> list[str]: ) -> list[str]:
exts = await (conn or db).fetchall( exts = await (conn or db).fetchall(
""" """
+76 -143
View File
@@ -1,7 +1,7 @@
from time import time from time import time
from typing import Any from typing import Any, Optional, Tuple
from lnbits.core.crud.wallets import get_total_balance, get_wallet, get_wallets_ids from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentState from lnbits.core.models import PaymentState
from lnbits.db import Connection, DateTrunc, Filters, Page from lnbits.db import Connection, DateTrunc, Filters, Page
@@ -23,7 +23,7 @@ def update_payment_extra():
pass pass
async def get_payment(checking_id: str, conn: Connection | None = None) -> Payment: async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id", "SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id}, {"checking_id": checking_id},
@@ -33,12 +33,13 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
async def get_standalone_payment( async def get_standalone_payment(
checking_id_or_hash: str, checking_id_or_hash: str,
incoming: bool | None = False, conn: Optional[Connection] = None,
wallet_id: str | None = None, incoming: Optional[bool] = False,
conn: Connection | None = None, wallet_id: Optional[str] = None,
) -> Payment | None: ) -> Optional[Payment]:
clause: str = "checking_id = :checking_id OR payment_hash = :hash" clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = { values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash, "checking_id": checking_id_or_hash,
"hash": checking_id_or_hash, "hash": checking_id_or_hash,
} }
@@ -46,20 +47,14 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0" clause = f"({clause}) AND amount > 0"
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
values["wallet_id"] = wallet.source_wallet_id
clause = f"({clause}) AND wallet_id = :wallet_id" clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
f""" f"""
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE {clause} WHERE {clause}
ORDER BY amount LIMIT 1 ORDER BY amount LIMIT 1
""", # noqa: S608 """,
values, values,
Payment, Payment,
) )
@@ -67,18 +62,15 @@ async def get_standalone_payment(
async def get_wallet_payment( async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Payment | None: ) -> Optional[Payment]:
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet or not wallet.can_view_payments:
return None
payment = await (conn or db).fetchone( payment = await (conn or db).fetchone(
""" """
SELECT * SELECT *
FROM apipayments FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash WHERE wallet_id = :wallet AND payment_hash = :hash
""", """,
{"wallet": wallet.source_wallet_id, "hash": payment_hash}, {"wallet": wallet_id, "hash": payment_hash},
Payment, Payment,
) )
return payment return payment
@@ -88,42 +80,36 @@ async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5 ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]: ) -> list[Payment]:
return await db.fetchall( return await db.fetchall(
# This query is safe from SQL injection
# The limtit is an integer and not user input
f""" f"""
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE status = :status WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE :ext_name AND extra LIKE :ext_name
AND extra LIKE :ext_id AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)} ORDER BY time DESC LIMIT {int(limit)}
""", # noqa: S608 """,
{ {"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
"status": f"{PaymentState.SUCCESS}",
"ext_name": f"%{ext_name}%",
"ext_id": f"%{ext_id}%",
},
Payment, Payment,
) )
async def get_payments_paginated( # noqa: C901 async def get_payments_paginated(
*, *,
wallet_id: str | None = None, wallet_id: Optional[str] = None,
user_id: str | None = None,
complete: bool = False, complete: bool = False,
pending: bool = False, pending: bool = False,
failed: bool = False, failed: bool = False,
outgoing: bool = False, outgoing: bool = False,
incoming: bool = False, incoming: bool = False,
since: int | None = None, since: Optional[int] = None,
exclude_uncheckable: bool = False, exclude_uncheckable: bool = False,
filters: Filters[PaymentFilters] | None = None, filters: Optional[Filters[PaymentFilters]] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Page[Payment]: ) -> Page[Payment]:
""" """
Filters payments to be returned by: Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming. - complete | pending | failed | outgoing | incoming.
""" """
values: dict[str, Any] = { values: dict[str, Any] = {
"time": since, "time": since,
} }
@@ -133,15 +119,8 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}") clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) values["wallet_id"] = wallet_id
if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0)
values["wallet_id"] = wallet.source_wallet_id
clause.append("wallet_id = :wallet_id") clause.append("wallet_id = :wallet_id")
elif user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
clause.append(only_user_wallets)
if complete and pending: if complete and pending:
clause.append( clause.append(
@@ -180,23 +159,22 @@ async def get_payments_paginated( # noqa: C901
values, values,
filters=filters, filters=filters,
model=Payment, model=Payment,
table_name="apipayments",
) )
async def get_payments( async def get_payments(
*, *,
wallet_id: str | None = None, wallet_id: Optional[str] = None,
complete: bool = False, complete: bool = False,
pending: bool = False, pending: bool = False,
outgoing: bool = False, outgoing: bool = False,
incoming: bool = False, incoming: bool = False,
since: int | None = None, since: Optional[int] = None,
exclude_uncheckable: bool = False, exclude_uncheckable: bool = False,
filters: Filters[PaymentFilters] | None = None, filters: Optional[Filters[PaymentFilters]] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
limit: int | None = None, limit: Optional[int] = None,
offset: int | None = None, offset: Optional[int] = None,
) -> list[Payment]: ) -> list[Payment]:
""" """
Filters payments to be returned by complete | pending | outgoing | incoming. Filters payments to be returned by complete | pending | outgoing | incoming.
@@ -240,28 +218,26 @@ async def get_payments_status_count() -> PaymentsStatusCount:
async def delete_expired_invoices( async def delete_expired_invoices(
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
# first we delete all invoices older than one month # first we delete all invoices older than one month
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE status = :status AND amount > 0 WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_placeholder("delta")} AND time < {db.timestamp_placeholder("delta")}
""", # noqa: S608 """,
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)}, {"delta": int(time() - 2592000)},
) )
# then we delete all invoices whose expiry date is in the past # then we delete all invoices whose expiry date is in the past
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE FROM apipayments DELETE FROM apipayments
WHERE status = :status AND amount > 0 WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_placeholder("now")} AND expiry < {db.timestamp_placeholder("now")}
""", # noqa: S608 """,
{"status": f"{PaymentState.PENDING}", "now": int(time())}, {"now": int(time())},
) )
@@ -269,13 +245,12 @@ async def create_payment(
checking_id: str, checking_id: str,
data: CreatePayment, data: CreatePayment,
status: PaymentState = PaymentState.PENDING, status: PaymentState = PaymentState.PENDING,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
# we don't allow the creation of the same invoice twice # we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately # note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn) previous_payment = await get_standalone_payment(checking_id, conn=conn)
if previous_payment is not None: assert previous_payment is None, "Payment already exists"
raise ValueError("Payment already exists")
extra = data.extra or {} extra = data.extra or {}
payment = Payment( payment = Payment(
@@ -289,10 +264,9 @@ async def create_payment(
preimage=data.preimage, preimage=data.preimage,
expiry=data.expiry, expiry=data.expiry,
webhook=data.webhook, webhook=data.webhook,
fee=-abs(data.fee), fee=data.fee,
tag=extra.get("tag", None), tag=extra.get("tag", None),
extra=extra, extra=extra,
labels=data.labels or [],
) )
await (conn or db).insert("apipayments", payment) await (conn or db).insert("apipayments", payment)
@@ -301,7 +275,7 @@ async def create_payment(
async def update_payment_checking_id( async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id", "UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
@@ -311,8 +285,8 @@ async def update_payment_checking_id(
async def update_payment( async def update_payment(
payment: Payment, payment: Payment,
new_checking_id: str | None = None, new_checking_id: Optional[str] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
await (conn or db).update( await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id" "apipayments", payment, "WHERE checking_id = :checking_id"
@@ -322,17 +296,16 @@ async def update_payment(
async def get_payments_history( async def get_payments_history(
wallet_id: str | None = None, wallet_id: Optional[str] = None,
group: DateTrunc = "day", group: DateTrunc = "day",
filters: Filters | None = None, filters: Optional[Filters] = None,
conn: Connection | None = None,
) -> list[PaymentHistoryPoint]: ) -> list[PaymentHistoryPoint]:
if not filters: if not filters:
filters = Filters() filters = Filters()
date_trunc = db.datetime_grouping(group) date_trunc = db.datetime_grouping(group)
values: dict[str, Any] = { values = {
"wallet_id": wallet_id, "wallet_id": wallet_id,
} }
# count outgoing payments if they are still pending # count outgoing payments if they are still pending
@@ -344,30 +317,26 @@ async def get_payments_history(
) )
""" """
] ]
clause = filters.where(where)
transactions: list[dict] = await db.fetchall( transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection:
# - `date_trunc` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
f""" f"""
SELECT {date_trunc} date, SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income, SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments FROM apipayments
{clause} {filters.where(where)}
GROUP BY date GROUP BY date
ORDER BY date DESC ORDER BY date DESC
""", # noqa: S608 """,
filters.values(values), filters.values(values),
) )
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if wallet:
return [] balance = wallet.balance_msat
balance = wallet.balance_msat else:
values["wallet_id"] = wallet.source_wallet_id raise ValueError("Unknown wallet")
else: else:
balance = await get_total_balance(conn=conn) balance = await get_total_balance()
# since we dont know the balance at the starting point, # since we dont know the balance at the starting point,
# we take the current balance and walk backwards # we take the current balance and walk backwards
@@ -388,31 +357,21 @@ async def get_payments_history(
async def get_payment_count_stats( async def get_payment_count_stats(
field: PaymentCountField, field: PaymentCountField,
filters: Filters[PaymentFilters] | None = None, filters: Optional[Filters[PaymentFilters]] = None,
user_id: str | None = None, conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> list[PaymentCountStat]: ) -> list[PaymentCountStat]:
if not filters: if not filters:
filters = Filters() filters = Filters()
extra_stmts = [] clause = filters.where()
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
extra_stmts.append(only_user_wallets)
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall( data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f""" query=f"""
SELECT {field} as field, count(*) as total SELECT {field} as field, count(*) as total
FROM apipayments FROM apipayments
{clause} {clause}
GROUP BY {field} GROUP BY {field}
ORDER BY {field} ORDER BY {field}
""", # noqa: S608 """,
values=filters.values(), values=filters.values(),
model=PaymentCountStat, model=PaymentCountStat,
) )
@@ -421,27 +380,19 @@ async def get_payment_count_stats(
async def get_daily_stats( async def get_daily_stats(
filters: Filters[PaymentFilters] | None = None, filters: Optional[Filters[PaymentFilters]] = None,
user_id: str | None = None, conn: Optional[Connection] = None,
conn: Connection | None = None, ) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters: if not filters:
filters = Filters() filters = Filters()
in_where_stmts = ["(apipayments.status = 'success' AND apipayments.amount > 0)"] in_clause = filters.where(
out_where_stmts = [ ["(apipayments.status = 'success' AND apipayments.amount > 0)"]
"(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)" )
] out_clause = filters.where(
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
if user_id: )
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
in_where_stmts.append(only_user_wallets)
out_where_stmts.append(only_user_wallets)
in_clause = filters.where(in_where_stmts)
out_clause = filters.where(out_where_stmts)
date_trunc = db.datetime_grouping("day") date_trunc = db.datetime_grouping("day")
query = """ query = """
SELECT {date_trunc} date, SELECT {date_trunc} date,
@@ -471,9 +422,8 @@ async def get_daily_stats(
async def get_wallets_stats( async def get_wallets_stats(
filters: Filters[PaymentFilters] | None = None, filters: Optional[Filters[PaymentFilters]] = None,
user_id: str | None = None, conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> list[PaymentWalletStats]: ) -> list[PaymentWalletStats]:
if not filters: if not filters:
@@ -491,15 +441,9 @@ async def get_wallets_stats(
) )
""", """,
] ]
if user_id:
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
where_stmts.append(only_user_wallets)
clauses = filters.where(where_stmts) clauses = filters.where(where_stmts)
data = await (conn or db).fetchall( data = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clauses` is constructed from sanitized inputs
query=f""" query=f"""
SELECT apipayments.wallet_id, SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name, MAX(wallets.name) AS wallet_name,
@@ -511,7 +455,7 @@ async def get_wallets_stats(
{clauses} {clauses}
GROUP BY apipayments.wallet_id GROUP BY apipayments.wallet_id
ORDER BY payments_count ORDER BY payments_count
""", # noqa: S608 """,
values=filters.values(), values=filters.values(),
model=PaymentWalletStats, model=PaymentWalletStats,
) )
@@ -520,7 +464,7 @@ async def get_wallets_stats(
async def delete_wallet_payment( async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Connection | None = None checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet", "DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
@@ -529,24 +473,24 @@ async def delete_wallet_payment(
async def check_internal( async def check_internal(
payment_hash: str, conn: Connection | None = None payment_hash: str, conn: Optional[Connection] = None
) -> Payment | None: ) -> Optional[Payment]:
""" """
Returns the checking_id of the internal payment if it exists, Returns the checking_id of the internal payment if it exists,
otherwise None otherwise None
""" """
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" f"""
SELECT * FROM apipayments SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = :status AND amount > 0 WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
""", """,
{"status": f"{PaymentState.PENDING}", "hash": payment_hash}, {"hash": payment_hash},
Payment, Payment,
) )
async def is_internal_status_success( async def is_internal_status_success(
payment_hash: str, conn: Connection | None = None payment_hash: str, conn: Optional[Connection] = None
) -> bool: ) -> bool:
""" """
Returns True if the internal payment was found and is successful, Returns True if the internal payment was found and is successful,
@@ -564,7 +508,7 @@ async def is_internal_status_success(
return payment.status == PaymentState.SUCCESS.value return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: str) -> None: async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute( await db.execute(
""" """
UPDATE apipayments SET webhook_status = :status UPDATE apipayments SET webhook_status = :status
@@ -572,14 +516,3 @@ async def mark_webhook_sent(payment_hash: str, status: str) -> None:
""", """,
{"status": status, "hash": payment_hash}, {"status": status, "hash": payment_hash},
) )
async def _only_user_wallets_statement(
user_id: str, conn: Connection | None = None
) -> str:
wallet_ids = await get_wallets_ids(user_id=user_id, conn=conn) or [
"no-wallets-for-user"
]
# wallet ids are safe to use in sql queries
wallet_ids_str = [f"'{w}'" for w in wallet_ids]
return f""" wallet_id IN ({", ".join(wallet_ids_str)}) """
+14 -30
View File
@@ -1,5 +1,5 @@
import json import json
from typing import Any from typing import Any, Optional
from loguru import logger from loguru import logger
@@ -14,7 +14,7 @@ from lnbits.settings import (
) )
async def get_super_settings() -> SuperSettings | None: async def get_super_settings() -> Optional[SuperSettings]:
data = await get_settings_by_tag("core") data = await get_settings_by_tag("core")
if data: if data:
super_user = await get_settings_field("super_user") super_user = await get_settings_field("super_user")
@@ -24,7 +24,7 @@ async def get_super_settings() -> SuperSettings | None:
return None return None
async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | None: async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
sets = await get_super_settings() sets = await get_super_settings()
if not sets: if not sets:
return None return None
@@ -41,7 +41,7 @@ async def get_admin_settings(is_super_user: bool = False) -> AdminSettings | Non
async def update_admin_settings( async def update_admin_settings(
data: EditableSettings, tag: str | None = "core" data: EditableSettings, tag: Optional[str] = "core"
) -> None: ) -> None:
editable_settings = await get_settings_by_tag("core") or {} editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True)) editable_settings.update(data.dict(exclude_unset=True))
@@ -56,29 +56,12 @@ async def update_admin_settings(
async def update_super_user(super_user: str) -> SuperSettings: async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user) await set_settings_field("super_user", super_user)
settings = await get_super_settings() settings = await get_super_settings()
if not settings: assert settings, "updated super_user settings could not be retrieved"
raise ValueError("updated super_user settings could not be retrieved")
return settings return settings
async def delete_admin_settings(tag: str | None = "core") -> None: async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute( await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
"DELETE FROM system_settings WHERE tag = :tag",
{"tag": tag},
)
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings: async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
@@ -87,14 +70,13 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
await set_settings_field(key, value) await set_settings_field(key, value)
settings = await get_super_settings() settings = await get_super_settings()
if not settings: assert settings, "created admin settings could not be retrieved"
raise ValueError("created admin settings could not be retrieved")
return settings return settings
async def get_settings_field( async def get_settings_field(
id_: str, tag: str | None = "core" id_: str, tag: Optional[str] = "core"
) -> SettingsField | None: ) -> Optional[SettingsField]:
row: dict = await db.fetchone( row: dict = await db.fetchone(
""" """
@@ -108,7 +90,9 @@ async def get_settings_field(
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"]) return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"): async def set_settings_field(
id_: str, value: Optional[Any], tag: Optional[str] = "core"
):
value = json.dumps(value) if value is not None else None value = json.dumps(value) if value is not None else None
await db.execute( await db.execute(
""" """
@@ -120,7 +104,7 @@ async def set_settings_field(id_: str, value: Any | None, tag: str | None = "cor
) )
async def get_settings_by_tag(tag: str) -> dict[str, Any] | None: async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
rows: list[dict] = await db.fetchall( rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag} "SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
) )
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Optional
import shortuuid import shortuuid
from lnbits.core.db import db from lnbits.core.db import db
@@ -17,7 +19,7 @@ async def create_tinyurl(domain: str, endless: bool, wallet: str):
return await get_tinyurl(tinyurl_id) return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> TinyURL | None: async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
return await db.fetchone( return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl", "SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id}, {"tinyurl": tinyurl_id},
+42 -57
View File
@@ -1,10 +1,10 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from time import time from time import time
from typing import Any from typing import Any, Optional
from uuid import uuid4 from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import create_wallet, get_wallets from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import UserAcls from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
@@ -18,8 +18,8 @@ from ..models import (
async def create_account( async def create_account(
account: Account | None = None, account: Optional[Account] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Account: ) -> Account:
if account: if account:
account.validate_fields() account.validate_fields()
@@ -30,13 +30,13 @@ async def create_account(
return account return account
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await db.update("accounts", account)
return account return account
async def delete_account(user_id: str, conn: Connection | None = None) -> None: async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute( await (conn or db).execute(
"DELETE from accounts WHERE id = :user", "DELETE from accounts WHERE id = :user",
{"user": user_id}, {"user": user_id},
@@ -44,8 +44,8 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
async def get_accounts( async def get_accounts(
filters: Filters[AccountFilters] | None = None, filters: Optional[Filters[AccountFilters]] = None,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Page[AccountOverview]: ) -> Page[AccountOverview]:
where_clauses = [] where_clauses = []
values: dict[str, Any] = {} values: dict[str, Any] = {}
@@ -68,7 +68,6 @@ async def get_accounts(
accounts.username, accounts.username,
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.external_id,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat, ), 0)) as balance_msat,
@@ -89,11 +88,12 @@ async def get_accounts(
filters=filters, filters=filters,
model=AccountOverview, model=AccountOverview,
group_by=["accounts.id"], group_by=["accounts.id"],
table_name="accounts",
) )
async def get_account(user_id: str, conn: Connection | None = None) -> Account | None: async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(user_id) == 0: if len(user_id) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
@@ -105,11 +105,10 @@ async def get_account(user_id: str, conn: Connection | None = None) -> Account |
async def delete_accounts_no_wallets( async def delete_accounts_no_wallets(
time_delta: int, time_delta: int,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
delta = int(time()) - time_delta delta = int(time()) - time_delta
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
DELETE FROM accounts DELETE FROM accounts
WHERE NOT EXISTS ( WHERE NOT EXISTS (
@@ -118,83 +117,72 @@ async def delete_accounts_no_wallets(
(updated_at is null AND created_at < :delta) (updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")} OR updated_at < {db.timestamp_placeholder("delta")}
) )
""", # noqa: S608 """,
{"delta": delta}, {"delta": delta},
) )
async def get_account_by_username( async def get_account_by_username(
username: str, conn: Connection | None = None username: str, conn: Optional[Connection] = None
) -> Account | None: ) -> Optional[Account]:
if len(username) == 0: if len(username) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(username) = :username", "SELECT * FROM accounts WHERE username = :username",
{"username": username.lower()}, {"username": username},
Account, Account,
) )
async def get_account_by_pubkey( async def get_account_by_pubkey(
pubkey: str, conn: Connection | None = None pubkey: str, conn: Optional[Connection] = None
) -> Account | None: ) -> Optional[Account]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey", "SELECT * FROM accounts WHERE pubkey = :pubkey",
{"pubkey": pubkey.lower()}, {"pubkey": pubkey},
Account, Account,
) )
async def get_account_by_email( async def get_account_by_email(
email: str, conn: Connection | None = None email: str, conn: Optional[Connection] = None
) -> Account | None: ) -> Optional[Account]:
if len(email) == 0: if len(email) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE LOWER(email) = :email", "SELECT * FROM accounts WHERE email = :email",
{"email": email.lower()}, {"email": email},
Account, Account,
) )
async def get_account_by_username_or_email( async def get_account_by_username_or_email(
username_or_email: str, conn: Connection | None = None username_or_email: str, conn: Optional[Connection] = None
) -> Account | None: ) -> Optional[Account]:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE email = :value or username = :value",
SELECT * FROM accounts {"value": username_or_email},
WHERE LOWER(email) = :value or LOWER(username) = :value
""",
{"value": username_or_email.lower()},
Account, Account,
) )
async def get_user(user_id: str, conn: Connection | None = None) -> User | None: async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
async with db.reuse_conn(conn) if conn else db.connect() as conn: account = await get_account(user_id, conn)
account = await get_account(user_id, conn=conn) if not account:
if not account: return None
return None return await get_user_from_account(account, conn)
return await get_user_from_account(account, conn=conn)
async def get_user_from_account( async def get_user_from_account(
account: Account, conn: Connection | None = None account: Account, conn: Optional[Connection] = None
) -> User | None: ) -> Optional[User]:
async with db.reuse_conn(conn) if conn else db.connect() as conn: extensions = await get_user_active_extensions_ids(account.id, conn)
extensions = await get_user_active_extensions_ids(account.id, conn=conn) wallets = await get_wallets(account.id, False, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet)
return User( return User(
id=account.id, id=account.id,
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, pubkey=account.pubkey,
external_id=account.external_id,
extra=account.extra, extra=account.extra,
created_at=account.created_at, created_at=account.created_at,
updated_at=account.updated_at, updated_at=account.updated_at,
@@ -202,20 +190,17 @@ async def get_user_from_account(
wallets=wallets, wallets=wallets,
admin=account.is_admin, admin=account.is_admin,
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
) )
async def update_user_access_control_list( async def update_user_access_control_list(user_acls: UserAcls):
user_acls: UserAcls, conn: Connection | None = None
):
user_acls.updated_at = datetime.now(timezone.utc) user_acls.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", user_acls) await db.update("accounts", user_acls)
async def get_user_access_control_lists( async def get_user_access_control_lists(
user_id: str, conn: Connection | None = None user_id: str, conn: Optional[Connection] = None
) -> UserAcls: ) -> UserAcls:
user_acls = await (conn or db).fetchone( user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id", "SELECT id, access_control_list FROM accounts WHERE id = :id",
+38 -170
View File
@@ -1,12 +1,11 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from time import time from time import time
from typing import Optional
from uuid import uuid4 from uuid import uuid4
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType from lnbits.db import Connection
from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
from ..models import Wallet from ..models import Wallet
@@ -14,31 +13,26 @@ from ..models import Wallet
async def create_wallet( async def create_wallet(
*, *,
user_id: str, user_id: str,
wallet_name: str | None = None, wallet_name: Optional[str] = None,
wallet_type: WalletType = WalletType.LIGHTNING, conn: Optional[Connection] = None,
shared_wallet_id: str | None = None,
conn: Connection | None = None,
) -> Wallet: ) -> Wallet:
wallet_id = uuid4().hex wallet_id = uuid4().hex
wallet = Wallet( wallet = Wallet(
id=wallet_id, id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name, name=wallet_name or settings.lnbits_default_wallet_name,
wallet_type=wallet_type.value,
shared_wallet_id=shared_wallet_id,
user=user_id, user=user_id,
adminkey=uuid4().hex, adminkey=uuid4().hex,
inkey=uuid4().hex, inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD", currency=settings.lnbits_default_accounting_currency or "USD",
) )
await (conn or db).insert("wallets", wallet) await (conn or db).insert("wallets", wallet)
return wallet return wallet
async def update_wallet( async def update_wallet(
wallet: Wallet, wallet: Wallet,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Wallet: ) -> Optional[Wallet]:
wallet.updated_at = datetime.now(timezone.utc) wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet) await (conn or db).update("wallets", wallet)
return wallet return wallet
@@ -49,26 +43,22 @@ async def delete_wallet(
user_id: str, user_id: str,
wallet_id: str, wallet_id: str,
deleted: bool = True, deleted: bool = True,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
now = int(time()) now = int(time())
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE wallets UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')} SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user WHERE id = :wallet AND "user" = :user
""", # noqa: S608 """,
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now}, {"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
) )
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None: async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet", "DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id}, {"wallet": wallet_id},
@@ -76,28 +66,27 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id( async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None wallet_id: str, conn: Optional[Connection] = None
) -> int | None: ) -> Optional[int]:
now = int(time()) now = int(time())
result = await (conn or db).execute( result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE wallets UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')} SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet WHERE id = :wallet
""", # noqa: S608 """,
{"wallet": wallet_id, "now": now}, {"wallet": wallet_id, "now": now},
) )
return result.rowcount return result.rowcount
async def remove_deleted_wallets(conn: Connection | None = None) -> None: async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true") await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets( async def delete_unused_wallets(
time_delta: int, time_delta: int,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> None: ) -> None:
delta = int(time()) - time_delta delta = int(time()) - time_delta
await (conn or db).execute( await (conn or db).execute(
@@ -114,107 +103,37 @@ async def delete_unused_wallets(
) )
async def get_standalone_wallet( async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Wallet | None: ) -> Optional[Wallet]:
query = """ where = "AND deleted = :deleted" if deleted is not None else ""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone( return await (conn or db).fetchone(
query, f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet {where}
""",
{"wallet": wallet_id, "deleted": deleted}, {"wallet": wallet_id, "deleted": deleted},
Wallet, Wallet,
) )
async def get_wallet(
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> Wallet | None:
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
return await get_source_wallet(wallet, conn)
return wallet
async def get_wallets( async def get_wallets(
user_id: str, user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
deleted: bool | None = False,
wallet_type: WalletType | None = None,
conn: Connection | None = None,
) -> list[Wallet]: ) -> list[Wallet]:
query = """ where = "AND deleted = :deleted" if deleted is not None else ""
SELECT *, COALESCE(( return await (conn or db).fetchall(
SELECT balance FROM balances WHERE wallet_id = wallets.id f"""
), 0) AS balance_msat FROM wallets SELECT *, COALESCE((
WHERE "user" = :user SELECT balance FROM balances WHERE wallet_id = wallets.id
""" ), 0) AS balance_msat FROM wallets
if deleted is not None: WHERE "user" = :user {where}
query += " AND deleted = :deleted "
if wallet_type is not None:
query += " AND wallet_type = :wallet_type "
wallets = await (conn or db).fetchall(
query,
{
"user": user_id,
"deleted": deleted,
"wallet_type": wallet_type.value if wallet_type else None,
},
Wallet,
)
return await get_source_wallets(wallets, conn)
async def get_wallets_paginated(
user_id: str,
deleted: bool | None = None,
filters: Filters[WalletsFilters] | None = None,
conn: Connection | None = None,
) -> Page[Wallet]:
if deleted is None:
deleted = False
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
wallets = await (conn or db).fetch_page(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
""", """,
where=where,
values={"user": user_id, "deleted": deleted},
filters=filters,
model=Wallet,
table_name="wallets",
)
wallets.data = await get_source_wallets(wallets.data, conn)
return wallets
async def get_wallets_ids(
user_id: str, deleted: bool | None = False, conn: Connection | None = None
) -> list[str]:
query = """SELECT * FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += " AND deleted = :deleted "
wallets = await (conn or db).fetchall(
query,
{"user": user_id, "deleted": deleted}, {"user": user_id, "deleted": deleted},
Wallet, Wallet,
) )
wallets = await get_source_wallets(wallets, conn)
return [w.source_wallet_id for w in wallets if w.can_view_payments]
async def get_wallets_count(): async def get_wallets_count():
result = await db.execute("SELECT COUNT(*) as count FROM wallets") result = await db.execute("SELECT COUNT(*) as count FROM wallets")
@@ -224,9 +143,9 @@ async def get_wallets_count():
async def get_wallet_for_key( async def get_wallet_for_key(
key: str, key: str,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Wallet | None: ) -> Optional[Wallet]:
wallet = await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT *, COALESCE(( SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
@@ -237,60 +156,9 @@ async def get_wallet_for_key(
{"key": key}, {"key": key},
Wallet, Wallet,
) )
if not wallet:
return None
if wallet.is_lightning_shared_wallet:
mw = await get_source_wallet(wallet, conn)
return mw
return wallet
async def get_base_wallet_for_key( async def get_total_balance(conn: Optional[Connection] = None):
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT id, "user", wallet_type, adminkey, inkey FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None
) -> Wallet | None:
if not wallet.is_lightning_shared_wallet:
return wallet
if not wallet.shared_wallet_id:
return None
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
if not shared_wallet:
return None
wallet.mirror_shared_wallet(shared_wallet)
return wallet
async def get_source_wallets(
wallet: list[Wallet], conn: Connection | None = None
) -> list[Wallet]:
source_wallets = []
for w in wallet:
source_wallet = await get_source_wallet(w, conn)
if source_wallet:
source_wallets.append(source_wallet)
return source_wallets
async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances") result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first() row = result.mappings().first()
return row.get("balance", 0) or 0 return row.get("balance", 0) or 0
+4 -3
View File
@@ -1,3 +1,5 @@
from typing import Optional
from lnbits.core.db import db from lnbits.core.db import db
from ..models import WebPushSubscription from ..models import WebPushSubscription
@@ -5,7 +7,7 @@ from ..models import WebPushSubscription
async def get_webpush_subscription( async def get_webpush_subscription(
endpoint: str, user: str endpoint: str, user: str
) -> WebPushSubscription | None: ) -> Optional[WebPushSubscription]:
return await db.fetchone( return await db.fetchone(
""" """
SELECT * FROM webpush_subscriptions SELECT * FROM webpush_subscriptions
@@ -35,8 +37,7 @@ async def create_webpush_subscription(
{"endpoint": endpoint, "user": user, "data": data, "host": host}, {"endpoint": endpoint, "user": user, "data": data, "host": host},
) )
subscription = await get_webpush_subscription(endpoint, user) subscription = await get_webpush_subscription(endpoint, user)
if not subscription: assert subscription, "Newly created webpush subscription couldn't be retrieved"
raise ValueError("Newly created webpush subscription couldn't be retrieved")
return subscription return subscription
+3 -3
View File
@@ -1,6 +1,6 @@
import importlib import importlib
import re import re
from typing import Any from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
@@ -20,7 +20,7 @@ from lnbits.settings import settings
async def migrate_extension_database( async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None ext: InstallableExtension, current_version: Optional[DbVersion] = None
): ):
try: try:
@@ -38,7 +38,7 @@ async def run_migration(
db: Connection, db: Connection,
migrations_module: Any, migrations_module: Any,
db_name: str, db_name: str,
current_version: DbVersion | None = None, current_version: Optional[DbVersion] = None,
): ):
matcher = re.compile(r"^m(\d\d\d)_") matcher = re.compile(r"^m(\d\d\d)_")
+8 -145
View File
@@ -214,7 +214,6 @@ async def m007_set_invoice_expiries(db: Connection):
""" """
try: try:
result = await db.execute( result = await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
SELECT bolt11, checking_id SELECT bolt11, checking_id
FROM apipayments FROM apipayments
@@ -223,7 +222,7 @@ async def m007_set_invoice_expiries(db: Connection):
AND bolt11 IS NOT NULL AND bolt11 IS NOT NULL
AND expiry IS NULL AND expiry IS NULL
AND time < {db.timestamp_now} AND time < {db.timestamp_now}
""" # noqa: S608 """
) )
rows = result.mappings().all() rows = result.mappings().all()
if len(rows): if len(rows):
@@ -243,15 +242,13 @@ async def m007_set_invoice_expiries(db: Connection):
f" {invoice.payment_hash} to {expiration_date}" f" {invoice.payment_hash} to {expiration_date}"
) )
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')} UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0 WHERE checking_id = :checking_id AND amount > 0
""", # noqa: S608 """,
{"expiry": expiration_date, "checking_id": checking_id}, {"expiry": expiration_date, "checking_id": checking_id},
) )
except Exception as exc: except Exception:
logger.debug(exc)
continue continue
except OperationalError: except OperationalError:
# this is necessary now because it may be the case that this migration will # this is necessary now because it may be the case that this migration will
@@ -374,8 +371,7 @@ async def m014_set_deleted_wallets(db: Connection):
"wallet": row.get("id"), "wallet": row.get("id"),
}, },
) )
except Exception as exc: except Exception:
logger.debug(exc)
continue continue
except OperationalError: except OperationalError:
# this is necessary now because it may be the case that this migration will # this is necessary now because it may be the case that this migration will
@@ -458,19 +454,17 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
# set all to now where they are null # set all to now where they are null
now = int(time()) now = int(time())
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')} UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", # noqa: S608 """,
{"now": now}, {"now": now},
) )
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')} UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL WHERE created_at IS NULL
""", # noqa: S608 """,
{"now": now}, {"now": now},
) )
@@ -622,12 +616,7 @@ async def m027_update_apipayments_data(db: Connection):
logger.info(f"Updating {offset} to {offset+limit}") logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute( result = await db.execute(
# Limit and Offset safe from SQL injection f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
# since they are integers and are not user input
f"""
SELECT * FROM apipayments
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
""" # noqa: S608
) )
payments = result.mappings().all() payments = result.mappings().all()
logger.info(f"Payments count: {len(payments)}") logger.info(f"Payments count: {len(payments)}")
@@ -640,12 +629,11 @@ async def m027_update_apipayments_data(db: Connection):
tag = extra.get("tag") tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at") tsph = db.timestamp_placeholder("created_at")
await db.execute( await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
UPDATE apipayments UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph} SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id WHERE checking_id = :checking_id
""", # noqa: S608 """,
{ {
"tag": tag, "tag": tag,
"created_at": created_at, "created_at": created_at,
@@ -723,128 +711,3 @@ async def m031_add_color_and_icon_to_wallets(db: Connection):
Adds icon and color columns to wallets. Adds icon and color columns to wallets.
""" """
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT") await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
async def m032_add_external_id_to_accounts(db: Connection):
"""
Adds external_id column to accounts.
Used for external account linking.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN external_id TEXT")
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_add_stored_paylinks_to_wallet(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)
async def m035_add_wallet_type_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
"""
)
async def m036_add_shared_wallet_column(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
"""
)
async def m037_create_assets_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
mime_type TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT false,
name TEXT NOT NULL,
size_bytes INT NOT NULL,
thumbnail_base64 TEXT,
thumbnail {db.blob},
data {db.blob} NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m038_add_labels_for_payments(db: Connection):
await db.execute(
"""
ALTER TABLE apipayments ADD COLUMN labels TEXT
"""
)
async def m039_index_payments(db: Connection):
indexes = [
"wallet_id",
"checking_id",
"payment_hash",
"amount",
"fee",
"labels",
"time",
"status",
"memo",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
"""
)
async def m040_index_wallets(db: Connection):
indexes = [
"id",
"user",
"deleted",
"adminkey",
"inkey",
"wallet_type",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
"""
)
async def m042_index_accounts(db: Connection):
indexes = [
"id",
"email",
"username",
"pubkey",
"external_id",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
"""
)
+32 -27
View File
@@ -1,5 +1,5 @@
from .audit import AuditEntry, AuditFilters from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .misc import ( from .misc import (
BalanceDelta, BalanceDelta,
Callback, Callback,
@@ -9,7 +9,6 @@ from .misc import (
SimpleStatus, SimpleStatus,
) )
from .payments import ( from .payments import (
CancelInvoice,
CreateInvoice, CreateInvoice,
CreatePayment, CreatePayment,
DecodePayment, DecodePayment,
@@ -24,7 +23,6 @@ from .payments import (
PaymentsStatusCount, PaymentsStatusCount,
PaymentState, PaymentState,
PaymentWalletStats, PaymentWalletStats,
SettleInvoice,
) )
from .tinyurl import TinyURL from .tinyurl import TinyURL
from .users import ( from .users import (
@@ -46,59 +44,66 @@ from .users import (
UserAcls, UserAcls,
UserExtra, UserExtra,
) )
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [ __all__ = [
"AccessTokenPayload", # audit
"Account",
"AccountFilters",
"AccountOverview",
"AuditEntry", "AuditEntry",
"AuditFilters", "AuditFilters",
# lnurl
"CreateLnurl",
"CreateLnurlAuth",
"PayLnurlWData",
# misc
"BalanceDelta", "BalanceDelta",
"Callback", "Callback",
"CancelInvoice",
"ConversionData", "ConversionData",
"CoreAppExtra", "CoreAppExtra",
"CreateInvoice",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreatePayment",
"CreateUser",
"CreateWallet",
"CreateWebPushSubscription",
"DbVersion", "DbVersion",
"SimpleStatus",
# payments
"CreateInvoice",
"CreatePayment",
"DecodePayment", "DecodePayment",
"KeyType",
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice", "PayInvoice",
"Payment", "Payment",
"PaymentCountField", "PaymentCountField",
"PaymentCountStat", "PaymentCountStat",
"PaymentDailyStats", "PaymentDailyStats",
"PaymentsStatusCount",
"PaymentWalletStats",
"PaymentExtra", "PaymentExtra",
"PaymentFilters", "PaymentFilters",
"PaymentHistoryPoint", "PaymentHistoryPoint",
"PaymentState", "PaymentState",
"PaymentWalletStats", # tinyurl
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL", "TinyURL",
# users
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"UserAcls",
"CreateUser",
"RegisterUser",
"LoginUsernamePassword",
"LoginUsr",
"ResetUserPassword",
"UpdateBalance", "UpdateBalance",
"UpdateSuperuserPassword", "UpdateSuperuserPassword",
"UpdateUser", "UpdateUser",
"UpdateUserPassword", "UpdateUserPassword",
"UpdateUserPubkey", "UpdateUserPubkey",
"User", "User",
"UserAcls",
"UserExtra", "UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet", "Wallet",
"WalletInfo",
"WalletTypeInfo", "WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription", "WebPushSubscription",
] ]
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
class AssetInfo(BaseModel):
id: str
mime_type: str
name: str
is_public: bool = False
size_bytes: int
thumbnail_base64: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Asset(AssetInfo):
user_id: str
data: bytes
class AssetUpdate(BaseModel):
name: str | None = None
is_public: bool | None = None
class AssetFilters(FilterModel):
__search_fields__ = ["name"]
__sort_fields__ = [
"created_at",
"name",
]
name: str | None = None
+18 -79
View File
@@ -6,7 +6,6 @@ import json
import os import os
import shutil import shutil
import zipfile import zipfile
from asyncio.tasks import create_task
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -21,7 +20,6 @@ from lnbits.helpers import (
version_parse, version_parse,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
@@ -41,7 +39,6 @@ class ExplicitRelease(BaseModel):
info_notification: str | None info_notification: str | None
critical_notification: str | None critical_notification: str | None
details_link: str | None details_link: str | None
paid_features: str | None
pay_link: str | None pay_link: str | None
def is_version_compatible(self): def is_version_compatible(self):
@@ -190,7 +187,6 @@ class ExtensionRelease(BaseModel):
icon: str | None = None icon: str | None = None
details_link: str | None = None details_link: str | None = None
paid_features: str | None = None
pay_link: str | None = None pay_link: str | None = None
cost_sats: int | None = None cost_sats: int | None = None
paid_sats: int | None = 0 paid_sats: int | None = 0
@@ -215,8 +211,7 @@ class ExtensionRelease(BaseModel):
self, amount: int | None = None self, amount: int | None = None
) -> ReleasePaymentInfo | None: ) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
if not url: assert url, "Missing URL for payment info."
raise ValueError("Missing URL for payment info.")
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get(url) resp = await client.get(url)
@@ -260,7 +255,6 @@ class ExtensionRelease(BaseModel):
html_url=e.html_url, html_url=e.html_url,
details_link=e.details_link, details_link=e.details_link,
pay_link=e.pay_link, pay_link=e.pay_link,
paid_features=e.paid_features,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
) )
@@ -313,9 +307,6 @@ class ExtensionMeta(BaseModel):
dependencies: list[str] = [] dependencies: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
@@ -385,8 +376,9 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file(): if ext_zip_file.is_file():
os.remove(ext_zip_file) os.remove(ext_zip_file)
try: try:
if not self.meta or not self.meta.installed_release: assert (
raise ValueError("No installed release.") self.meta and self.meta.installed_release
), "installed_release is none."
self._restore_payment_info() self._restore_payment_info()
@@ -417,6 +409,7 @@ class InstallableExtension(BaseModel):
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash) tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True) shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref: with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir) zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0] generated_dir_name = os.listdir(tmp_dir)[0]
@@ -459,23 +452,9 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True) shutil.rmtree(self.ext_upgrade_dir, True)
def check_release_updates(self, release: ExtensionRelease | None): def check_latest_version(self, release: ExtensionRelease | None):
self._check_latest_version(release)
self._check_payment_link(release)
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _check_latest_version(self, release: ExtensionRelease | None):
if not release: if not release:
return return
if not release.is_version_compatible:
return
if not self.meta or not self.meta.latest_release: if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta() meta = self.meta or ExtensionMeta()
meta.latest_release = release meta.latest_release = release
@@ -486,19 +465,13 @@ class InstallableExtension(BaseModel):
): ):
self.meta.latest_release = release self.meta.latest_release = release
def _check_payment_link(self, release: ExtensionRelease | None): def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not release: if not pay_link or not self.meta or not self.meta.payments:
return return None
if not release.is_version_compatible: return next(
return (p for p in self.meta.payments if p.pay_link == pay_link),
if not self.meta: None,
self.meta = ExtensionMeta() )
if release.pay_link:
self.meta.has_paid_release = True
else:
self.meta.has_free_release = True
if release.paid_features:
self.meta.paid_features = release.paid_features
def _restore_payment_info(self): def _restore_payment_info(self):
if ( if (
@@ -608,37 +581,6 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, post_refresh_cache: bool = False
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
cache_key = "extensions:installable"
cache_value = cache.value(cache_key)
if not cache_value:
extension_list = await cls._get_installable_extensions()
cache.set(cache_key, extension_list, expiry=3600) # one hour
return extension_list
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@classmethod
async def _refresh_installable_extensions_cache(
cls,
) -> None:
cache_key = "extensions:installable"
extension_list: list[InstallableExtension] = (
await cls._get_installable_extensions()
)
cache.set(cache_key, extension_list, expiry=3600)
@classmethod
async def _get_installable_extensions(
cls, cls,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
@@ -655,7 +597,7 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == r.id), None (ee for ee in extension_list if ee.id == r.id), None
) )
if existing_ext and ext.meta: if existing_ext and ext.meta:
existing_ext.check_release_updates(ext.meta.latest_release) existing_ext.check_latest_version(ext.meta.latest_release)
continue continue
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
@@ -669,10 +611,10 @@ class InstallableExtension(BaseModel):
(ee for ee in extension_list if ee.id == e.id), None (ee for ee in extension_list if ee.id == e.id), None
) )
if existing_ext: if existing_ext:
existing_ext.check_release_updates(release) existing_ext.check_latest_version(release)
continue continue
ext = InstallableExtension.from_explicit_release(e) ext = InstallableExtension.from_explicit_release(e)
ext.check_release_updates(release) ext.check_latest_version(release)
meta = ext.meta or ExtensionMeta() meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured meta.featured = ext.id in manifest.featured
ext.meta = meta ext.meta = meta
@@ -686,11 +628,8 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]: async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = [] extension_releases: list[ExtensionRelease] = []
all_manifests = [
*settings.lnbits_extensions_manifests, for url in settings.lnbits_extensions_manifests:
settings.lnbits_extensions_builder_manifest_url,
]
for url in all_manifests:
try: try:
manifest = await cls.fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:
-460
View File
@@ -1,460 +0,0 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from pydantic import BaseModel, validator
from lnbits.helpers import (
camel_to_snake,
is_camel_case,
is_snake_case,
urlsafe_short_hash,
)
class DataField(BaseModel):
name: str
type: str
label: str | None = None
hint: str | None = None
optional: bool = False
editable: bool = False
searchable: bool = False
sortable: bool = False
fields: list[DataField] = []
def normalize(self) -> None:
self.name = self.name.strip()
self.type = self.type.strip()
if self.label:
self.label = self.label.strip()
if self.hint:
self.hint = self.hint.strip()
if self.type == "json":
self.editable = False
self.searchable = False
self.sortable = False
else:
self.fields = []
for field in self.fields:
field.normalize()
def field_to_py(self) -> str:
field_name = camel_to_snake(self.name)
field_type = self.type
if self.type == "json":
field_type = "dict"
elif self.type in ["wallet", "currency", "text"]:
field_type = "str"
if self.optional:
field_type += " | None"
if self.type == "currency":
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
if self.type == "json":
default_value = "{}"
if self.type == "currency":
default_value = '"sat"'
return f"{field_name}: {default_value}"
def field_to_ui_table_column(self) -> str:
column = {
"name": self.name,
"align": "left",
"label": self.label or self.name,
"field": self.name,
"sortable": self.sortable,
}
return json.dumps(column)
def field_to_db(self) -> str:
field_name = camel_to_snake(self.name)
field_type = self.type
if field_type == "str":
db_type = "TEXT"
elif field_type == "int":
db_type = "INT"
elif field_type == "float":
db_type = "REAL"
elif field_type == "bool":
db_type = "BOOLEAN"
elif field_type == "datetime":
db_type = "TIMESTAMP"
else:
db_type = "TEXT"
db_field = f"{field_name} {db_type}"
if not self.optional:
db_field += " NOT NULL"
if field_type == "json":
db_field += " DEFAULT '{empty_dict}'"
return db_field
def field_mock_value(self, index: int) -> Any:
if self.name == "id":
return urlsafe_short_hash()
if self.type == "int":
return index
elif self.type == "float":
return float(f"{index}.0{index * 2}")
elif self.type == "bool":
return True if index % 2 == 0 else False
elif self.type == "datetime":
return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
elif self.type == "json":
return {"key": "value"}
elif self.type == "currency":
return "USD"
else:
return f"{self.name} {index}"
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Field name is required.")
if not is_snake_case(v):
raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v
@validator("type")
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Owner Data type is required")
if v not in [
"str",
"int",
"float",
"bool",
"datetime",
"json",
"wallet",
"currency",
"text",
]:
raise ValueError(
"Field Type must be one of: "
"str, int, float, bool, datetime, json, wallet, currency, text."
f" Found: {v}"
)
return v
@validator("label")
def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field label cannot contain double quotes ("). Value: {v}'
)
return v
@validator("hint")
def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
return v
class DataFields(BaseModel):
name: str
editable: bool = True
fields: list[DataField] = []
def __init__(self, **data):
super().__init__(**data)
self.normalize()
def normalize(self) -> None:
self.name = self.name.strip()
for field in self.fields:
field.normalize()
if all(not field.editable for field in self.fields):
self.editable = False
def get_field_by_name(self, name: str | None) -> DataField | None:
if not name:
return None
for field in self.fields:
if field.name == name:
return field
return None
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Data fields name is required")
if not is_camel_case(v):
raise ValueError(f"Data name must be CamelCase. Found: {v}")
return v
class SettingsFields(DataFields):
enabled: bool = False
type: str = "user"
@validator("type")
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Settings type is required")
if v not in ["user", "admin"]:
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
return v
class ActionFields(BaseModel):
generate_action: bool = False
generate_payment_logic: bool = False
wallet_id: str | None = None
currency: str | None = None
amount: str | None = None
amount_source: Literal["owner_data", "client_data"] | None = None
paid_flag: str | None = None
class OwnerDataFields(BaseModel):
name: str | None = None
description: str | None = None
class ClientDataFields(BaseModel):
public_inputs: list[str] = []
class PublicPageFields(BaseModel):
has_public_page: bool = False
owner_data_fields: OwnerDataFields
client_data_fields: ClientDataFields
action_fields: ActionFields
class PreviewAction(BaseModel):
is_preview_mode: bool = False
is_settings_preview: bool = False
is_owner_data_preview: bool = False
is_client_data_preview: bool = False
is_public_page_preview: bool = False
def __init__(self, **data):
super().__init__(**data)
if not self.is_preview_mode:
self.is_settings_preview = False
self.is_owner_data_preview = False
self.is_client_data_preview = False
self.is_public_page_preview = False
class ExtensionData(BaseModel):
id: str
name: str
stub_version: str | None
short_description: str | None = None
description: str | None = None
owner_data: DataFields
client_data: DataFields
settings_data: SettingsFields
public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction()
def __init__(self, **data):
super().__init__(**data)
self.validate_data()
self.normalize()
def normalize(self) -> None:
self.id = self.id.strip()
self.name = self.name.strip()
if self.stub_version:
self.stub_version = self.stub_version.strip()
if self.short_description:
self.short_description = self.short_description.strip()
if self.description:
self.description = self.description.strip()
if not self.public_page.has_public_page:
self.public_page.action_fields.generate_action = False
self.public_page.action_fields.generate_payment_logic = False
if not self.public_page.action_fields.generate_action:
self.public_page.action_fields.generate_payment_logic = False
def validate_data(self) -> None:
self._validate_field_names()
self._validate_public_page_fields()
self._validate_action_fields()
def _validate_public_page_fields(self) -> None:
if not self.public_page.has_public_page:
return
public_page_name = self.public_page.owner_data_fields.name
if public_page_name:
public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
if not public_page_name_field:
raise ValueError(
"Public Page Name must be one of the owner data fields."
f" Received: {public_page_name}."
)
public_page_description = self.public_page.owner_data_fields.description
if public_page_description:
public_page_description_field = self.owner_data.get_field_by_name(
public_page_description
)
if not public_page_description_field:
raise ValueError(
"Public Page Description must be one of the owner data fields."
f" Received: {public_page_description}."
)
public_page_inputs = self.public_page.client_data_fields.public_inputs
if public_page_inputs:
for input_field in public_page_inputs:
input_field_obj = self.client_data.get_field_by_name(input_field)
if not input_field_obj:
raise ValueError(
"Public Page Input fields"
" must be one of the client data fields."
f" Received: {input_field}."
)
def _validate_action_fields(self) -> None:
if not self.public_page.action_fields.generate_action:
return
if not self.public_page.action_fields.generate_payment_logic:
return
self._validate_owner_data_fields()
self._validate_client_data_fields()
def _validate_owner_data_fields(self) -> None:
wallet_id = self.public_page.action_fields.wallet_id
if wallet_id:
wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
if not wallet_id_field:
raise ValueError(
"Action Wallet ID must be one of the owner data fields."
f" Received: {wallet_id}."
)
if wallet_id_field.type != "wallet":
raise ValueError(
"Action Wallet ID field type must be 'wallet'."
f" Received: {wallet_id_field.type}."
)
currency = self.public_page.action_fields.currency
if currency:
currency_field = self.owner_data.get_field_by_name(currency)
if not currency_field:
raise ValueError(
"Action Currency must be one of the owner data fields."
f" Received: {currency}."
)
if currency_field.type != "currency":
raise ValueError(
"Action Currency field type must be 'currency'."
f" Received: {currency_field.type}."
)
def _validate_field_names(self) -> None:
reserved_names = {"id", "created_at", "updated_at"}
nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
)
nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
)
nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
)
def _validate_client_data_fields(self) -> None:
amount = self.public_page.action_fields.amount
amount_source = self.public_page.action_fields.amount_source
if amount_source and amount:
if amount_source == "owner_data":
amount_field = self.owner_data.get_field_by_name(amount)
else:
amount_field = self.client_data.get_field_by_name(amount)
if not amount_field:
raise ValueError(
"Action Amount must be one of the "
"client data or owner data fields."
f" Received: {amount}."
)
if amount_field.type not in ["int", "float"]:
raise ValueError(
"Action Amount field type must be 'int' or 'float'."
f" Received: {amount_field.type}."
)
paid_flag = self.public_page.action_fields.paid_flag
if paid_flag:
paid_flag_field = self.client_data.get_field_by_name(paid_flag)
if not paid_flag_field:
raise ValueError(
"Action Paid Flag must be one of the client data fields."
f" Received: {paid_flag}."
)
if paid_flag_field.type != "bool":
raise ValueError(
"Action Paid Flag field type must be 'bool'."
f" Received: {paid_flag_field.type}."
)
@validator("id")
def validate_id(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension ID is required")
if not is_snake_case(v):
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension name is required")
return v
@validator("stub_version")
def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Extension stub version cannot contain double quotes ("). Value: {v}'
)
return v
@validator("short_description")
def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field short description cannot contain double quotes ("). Value: {v}'
)
return v
@validator("description")
def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field description cannot contain double quotes ("). Value: {v}'
)
return v
@validator("owner_data")
def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one owner data field is required")
return v
@validator("client_data")
def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one client data field is required")
return v
+12 -23
View File
@@ -1,31 +1,20 @@
from time import time from typing import Optional
from lnurl import LnAddress, Lnurl, LnurlPayResponse from pydantic import BaseModel
from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel): class CreateLnurl(BaseModel):
res: LnurlPayResponse | None = None description_hash: str
lnurl: Lnurl | LnAddress | None = None callback: str
amount: int amount: int
comment: str | None = None comment: Optional[str] = None
unit: str | None = None description: Optional[str] = None
internal_memo: str | None = None unit: Optional[str] = None
class CreateLnurlWithdraw(BaseModel): class CreateLnurlAuth(BaseModel):
lnurl_w: Lnurl callback: str
class LnurlScan(BaseModel): class PayLnurlWData(BaseModel):
lnurl: Lnurl | LnAddress lnurl_w: str
class StoredPayLink(BaseModel):
lnurl: str
label: str
last_used: int = Field(default_factory=lambda: int(time()))
class StoredPayLinks(BaseModel):
links: list[StoredPayLink] = []
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from typing import Callable
from pydantic import BaseModel from pydantic import BaseModel
-3
View File
@@ -2,8 +2,6 @@ from enum import Enum
from pydantic import BaseModel from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum): class NotificationType(Enum):
server_status = "server_status" server_status = "server_status"
@@ -20,7 +18,6 @@ class NotificationType(Enum):
class NotificationMessage(BaseModel): class NotificationMessage(BaseModel):
message_type: NotificationType message_type: NotificationType
values: dict values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = { NOTIFICATION_TEMPLATES = {
+6 -130
View File
@@ -5,17 +5,9 @@ from enum import Enum
from typing import Literal from typing import Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
@@ -59,7 +51,6 @@ class CreatePayment(BaseModel):
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
fee: int = 0 fee: int = 0
labels: list[str] | None = None
class Payment(BaseModel): class Payment(BaseModel):
@@ -69,29 +60,19 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
payment_request: str | None = Field(default=None, no_database=True)
fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
expiry: datetime | None = None expiry: datetime | None = None
webhook: str | None = None webhook: str | None = None
webhook_status: str | None = None webhook_status: int | None = None
preimage: str | None = None preimage: str | None = None
tag: str | None = None tag: str | None = None
extension: str | None = None extension: str | None = None
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {} extra: dict = {}
def __init__(self, **data):
super().__init__(**data)
if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"]
else:
self.payment_request = self.bolt11
@property @property
def pending(self) -> bool: def pending(self) -> bool:
return self.status == PaymentState.PENDING.value return self.status == PaymentState.PENDING.value
@@ -126,23 +107,14 @@ class Payment(BaseModel):
@property @property
def is_internal(self) -> bool: def is_internal(self) -> bool:
return self.checking_id.startswith("internal_") or self.checking_id.startswith( return self.checking_id.startswith("internal_")
"fiat_"
)
async def check_status( async def check_status(self) -> PaymentStatus:
self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if self.is_internal: if self.is_internal:
if self.success: if self.success:
return PaymentSuccessStatus() return PaymentSuccessStatus()
if self.failed: if self.failed:
return PaymentFailedStatus() return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus() return PaymentPendingStatus()
funding_source = get_funding_source() funding_source = get_funding_source()
if self.is_out: if self.is_out:
@@ -151,60 +123,11 @@ class Payment(BaseModel):
status = await funding_source.get_invoice_status(self.checking_id) status = await funding_source.get_invoice_status(self.checking_id)
return status return status
async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not self.is_internal:
return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
__search_fields__ = [ __search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
"memo",
"amount",
"wallet_id",
"tag",
"status",
"time",
"labels",
]
__sort_fields__ = [ __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
"created_at",
"updated_at",
"amount",
"fee",
"memo",
"time",
"tag",
]
status: str | None status: str | None
tag: str | None tag: str | None
@@ -216,7 +139,6 @@ class PaymentFilters(FilterModel):
preimage: str | None preimage: str | None
payment_hash: str | None payment_hash: str | None
wallet_id: str | None wallet_id: str | None
labels: str | None
class PaymentDataPoint(BaseModel): class PaymentDataPoint(BaseModel):
@@ -279,25 +201,11 @@ class CreateInvoice(BaseModel):
memo: str | None = Query(None, max_length=640) memo: str | None = Query(None, max_length=640)
description_hash: str | None = None description_hash: str | None = None
unhashed_description: str | None = None unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None expiry: int | None = None
extra: dict | None = None extra: dict | None = None
webhook: str | None = None webhook: str | None = None
bolt11: str | None = None bolt11: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None lnurl_callback: str | None = None
fiat_provider: str | None = None
labels: list[str] = []
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit") @validator("unit")
@classmethod @classmethod
@@ -312,35 +220,3 @@ class PaymentsStatusCount(BaseModel):
outgoing: int = 0 outgoing: int = 0
failed: int = 0 failed: int = 0
pending: int = 0 pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
+2 -1
View File
@@ -1,4 +1,5 @@
"""Keycloak SSO Login Helper""" """Keycloak SSO Login Helper
"""
from typing import Optional from typing import Optional
+15 -133
View File
@@ -3,48 +3,18 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.helpers import ( from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
is_valid_email_address,
is_valid_external_id,
is_valid_label,
is_valid_pubkey,
is_valid_username,
)
from lnbits.settings import settings from lnbits.settings import settings
from .wallets import Wallet from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class WalletInviteRequest(BaseModel):
request_id: str
from_user_name: str | None = None
to_wallet_id: str
to_wallet_name: str
class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250)
color: str | None = Field(
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel): class UserExtra(BaseModel):
email_verified: bool | None = False email_verified: bool | None = False
first_name: str | None = None first_name: str | None = None
@@ -57,60 +27,6 @@ class UserExtra(BaseModel):
# - "google | github | ...": the user was created using an SSO provider # - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider provider: str | None = "lnbits" # auth provider
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
wallet_invite_requests: list[WalletInviteRequest] = []
labels: list[UserLabel] = []
def add_wallet_invite_request(
self,
request_id: str,
to_wallet_id: str,
to_wallet_name: str,
from_user_name: str | None = None,
) -> WalletInviteRequest:
self.remove_wallet_invite_request(request_id)
invite = WalletInviteRequest(
request_id=request_id,
from_user_name=from_user_name,
to_wallet_id=to_wallet_id,
to_wallet_name=to_wallet_name,
)
self.wallet_invite_requests.append(invite)
return invite
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
for invite in self.wallet_invite_requests:
if invite.request_id == request_id:
return invite
return None
def validate_labels(self):
seen_labels = set()
for label in self.labels:
if not label.name:
raise ValueError("Label name cannot be empty.")
# apply the same rule for labels as for usernames
if not is_valid_label(label.name):
raise ValueError(f"Invalid label name: {label.name}")
if label.name in seen_labels:
raise ValueError(f"Duplicate label name: {label.name}")
seen_labels.add(label.name)
def remove_wallet_invite_request(
self,
request_id: str,
):
self.wallet_invite_requests = [
invite
for invite in self.wallet_invite_requests
if invite.request_id != request_id
]
class EndpointAccess(BaseModel): class EndpointAccess(BaseModel):
path: str path: str
@@ -172,16 +88,8 @@ class UserAcls(BaseModel):
return None return None
class AccountId(BaseModel): class Account(BaseModel):
id: str id: str
@property
def is_admin_id(self) -> bool:
return settings.is_admin_user(self.id)
class Account(AccountId):
external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
pubkey: str | None = None pubkey: str | None = None
@@ -193,32 +101,24 @@ class Account(AccountId):
is_super_user: bool = Field(default=False, no_database=True) is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True) is_admin: bool = Field(default=False, no_database=True)
fiat_providers: list[str] = Field(default=[], no_database=True)
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@property
def has_password(self) -> bool:
return self.password_hash is not None
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
salt = gensalt() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
hashed_pw = hashpw(password.encode(), salt) self.password_hash = pwd_context.hash(password)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
return self.password_hash return self.password_hash
def verify_password(self, password: str) -> bool: def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash""" """returns True if the password matches the hash"""
if not self.password_hash: if not self.password_hash:
return False return False
return checkpw(password.encode(), self.password_hash.encode()) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
def validate_fields(self): def validate_fields(self):
if self.username and not is_valid_username(self.username): if self.username and not is_valid_username(self.username):
@@ -227,17 +127,10 @@ class Account(AccountId):
raise ValueError("Invalid email.") raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey): if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.") raise ValueError("Invalid pubkey.")
if self.external_id and not is_valid_external_id(self.external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
user_uuid4 = UUID(hex=self.id, version=4) user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id: if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.") raise ValueError("User ID is not valid UUID4 hex string.")
self.extra.validate_labels()
class AccountOverview(Account): class AccountOverview(Account):
transaction_count: int | None = 0 transaction_count: int | None = 0
@@ -247,29 +140,20 @@ class AccountOverview(Account):
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
__search_fields__ = [ __search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
"id",
"email",
"username",
"pubkey",
"external_id",
"wallet_id",
]
__sort_fields__ = [ __sort_fields__ = [
"id", "balance_msat",
"email", "email",
"username", "username",
"pubkey", "transaction_count",
"external_id", "wallet_count",
"created_at", "last_payment",
"updated_at",
] ]
id: str | None = None
username: str | None = None
email: str | None = None email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None
wallet_id: str | None = None wallet_id: str | None = None
@@ -280,12 +164,10 @@ class User(BaseModel):
email: str | None = None email: str | None = None
username: str | None = None username: str | None = None
pubkey: str | None = None pubkey: str | None = None
external_id: str | None = None # for external account linking
extensions: list[str] = [] extensions: list[str] = []
wallets: list[Wallet] = [] wallets: list[Wallet] = []
admin: bool = False admin: bool = False
super_user: bool = False super_user: bool = False
fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
@@ -322,13 +204,13 @@ class CreateUser(BaseModel):
password: str | None = Query(default=None, min_length=8, max_length=50) password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50) password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64) pubkey: str = Query(default=None, max_length=64)
external_id: str = Query(default=None, max_length=256)
extensions: list[str] | None = None extensions: list[str] | None = None
extra: UserExtra | None = None extra: UserExtra | None = None
class UpdateUser(BaseModel): class UpdateUser(BaseModel):
user_id: str user_id: str
email: str | None = Query(default=None)
username: str | None = Query(default=..., min_length=2, max_length=20) username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None extra: UserExtra | None = None
+20 -184
View File
@@ -1,17 +1,20 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks from lnbits.helpers import url_for
from lnbits.db import FilterModel from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings from lnbits.settings import settings
class WalletInfo(BaseModel): class BaseWallet(BaseModel):
id: str id: str
name: str name: str
adminkey: str adminkey: str
@@ -19,175 +22,23 @@ class WalletInfo(BaseModel):
balance_msat: int balance_msat: int
class WalletType(Enum):
LIGHTNING = "lightning"
LIGHTNING_SHARED = "lightning-shared"
class WalletPermission(Enum):
VIEW_PAYMENTS = "view-payments"
RECEIVE_PAYMENTS = "receive-payments"
SEND_PAYMENTS = "send-payments"
def __str__(self):
return self.value
class WalletShareStatus(Enum):
INVITE_SENT = "invite_sent"
APPROVED = "approved"
class WalletSharePermission(BaseModel):
# unique identifier for this share request
request_id: str | None = None
# username of the invited user
username: str
# ID of the wallet being shared with
shared_with_wallet_id: str | None = None
# permissions being granted
permissions: list[WalletPermission] = []
# status of the share request
status: WalletShareStatus
comment: str | None = None
def approve(
self,
permissions: list[WalletPermission] | None = None,
shared_with_wallet_id: str | None = None,
):
self.status = WalletShareStatus.APPROVED
if permissions is not None:
self.permissions = permissions
if shared_with_wallet_id is not None:
self.shared_with_wallet_id = shared_with_wallet_id
@property
def is_approved(self) -> bool:
return self.status == WalletShareStatus.APPROVED
class WalletExtra(BaseModel): class WalletExtra(BaseModel):
icon: str = "flash_on" icon: str = "flash_on"
color: str = "primary" color: str = "primary"
pinned: bool = False
# What permissions this wallet grants when it's shared with other users
shared_with: list[WalletSharePermission] = []
def invite_user_to_shared_wallet(
self,
request_id: str,
request_type: WalletShareStatus,
username: str,
permissions: list[WalletPermission] | None = None,
) -> WalletSharePermission:
share = WalletSharePermission(
request_id=request_id,
username=username,
status=request_type,
permissions=permissions or [],
)
self.shared_with.append(share)
return share
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
for share in self.shared_with:
if share.request_id == request_id:
return share
return None
def find_share_for_wallet(
self, shared_with_wallet_id: str
) -> WalletSharePermission | None:
for share in self.shared_with:
if share.shared_with_wallet_id == shared_with_wallet_id:
return share
return None
def remove_share_by_id(self, request_id: str):
self.shared_with = [
share for share in self.shared_with if share.request_id != request_id
]
class BaseWallet(BaseModel): class Wallet(BaseModel):
id: str id: str
user: str user: str
wallet_type: str = WalletType.LIGHTNING.value name: str
adminkey: str adminkey: str
inkey: str inkey: str
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets
shared_wallet_id: str | None = None
deleted: bool = False deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None currency: str | None = None
balance_msat: int = Field(default=0, no_database=True) balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra() extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
# What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
def __init__(self, **data):
super().__init__(**data)
self._validate_data()
def mirror_shared_wallet(
self,
shared_wallet: Wallet,
):
if not shared_wallet.is_lightning_wallet:
return None
self.wallet_type = WalletType.LIGHTNING_SHARED.value
self.shared_wallet_id = shared_wallet.id
self.name = shared_wallet.name
self.share_permissions = shared_wallet.get_share_permissions(self.id)
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
self.extra.icon = shared_wallet.extra.icon
self.extra.color = shared_wallet.extra.color
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
for share in self.extra.shared_with:
if share.shared_with_wallet_id == wallet_id and share.is_approved:
return share.permissions
return []
def has_permission(self, permission: WalletPermission) -> bool:
if self.is_lightning_wallet:
return True
if self.is_lightning_shared_wallet:
return permission in self.share_permissions
return False
@property
def source_wallet_id(self) -> str:
"""For shared wallets return the original wallet ID, else return own ID."""
if self.is_lightning_shared_wallet and len(self.share_permissions):
return self.shared_wallet_id or self.id
return self.id
@property
def can_receive_payments(self) -> bool:
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
@property
def can_send_payments(self) -> bool:
return self.has_permission(WalletPermission.SEND_PAYMENTS)
@property
def can_view_payments(self) -> bool:
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
@property @property
def balance(self) -> int: def balance(self) -> int:
@@ -198,23 +49,24 @@ class Wallet(BaseWallet):
return self.balance_msat - settings.fee_reserve(self.balance_msat) return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property @property
def is_lightning_wallet(self) -> bool: def lnurlwithdraw_full(self) -> str:
return self.wallet_type == WalletType.LIGHTNING.value url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
@property def lnurlauth_key(self, domain: str) -> SigningKey:
def is_lightning_shared_wallet(self) -> bool: hashing_key = hashlib.sha256(self.id.encode()).digest()
return self.wallet_type == WalletType.LIGHTNING_SHARED.value linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
def _validate_data(self): return SigningKey.from_string(
if self.is_lightning_shared_wallet: linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
if not self.shared_wallet_id: )
raise ValueError("Shared wallet ID must be set for shared wallets.")
class CreateWallet(BaseModel): class CreateWallet(BaseModel):
name: str | None = None name: str | None = None
wallet_type: WalletType = WalletType.LIGHTNING
shared_wallet_id: str | None = None
class KeyType(Enum): class KeyType(Enum):
@@ -231,19 +83,3 @@ class KeyType(Enum):
class WalletTypeInfo: class WalletTypeInfo:
key_type: KeyType key_type: KeyType
wallet: Wallet wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
id: str | None
name: str | None
currency: str | None
+21 -29
View File
@@ -2,24 +2,17 @@ from .funding_source import (
get_balance_delta, get_balance_delta,
switch_to_voidwallet, switch_to_voidwallet,
) )
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl, perform_withdraw from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_admin_notification, send_payment_notification from .notifications import enqueue_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice,
create_invoice, create_invoice,
create_payment_request,
create_wallet_invoice,
fee_reserve, fee_reserve,
fee_reserve_total, fee_reserve_total,
get_payments_daily_stats,
pay_invoice, pay_invoice,
service_fee, service_fee,
settle_hold_invoice,
update_pending_payment,
update_pending_payments, update_pending_payments,
update_wallet_balance, update_wallet_balance,
) )
@@ -37,37 +30,36 @@ from .users import (
from .websockets import websocket_manager, websocket_updater from .websockets import websocket_manager, websocket_updater
__all__ = [ __all__ = [
# funding source
"get_balance_delta",
"switch_to_voidwallet",
# lnurl
"redeem_lnurl_withdraw",
"perform_lnurlauth",
# notifications
"enqueue_notification",
"send_payment_notification",
# payments
"calculate_fiat_amounts", "calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
"check_webpush_settings",
"create_fiat_invoice",
"create_invoice", "create_invoice",
"create_payment_request",
"create_user_account",
"create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_admin_notification",
"fee_reserve", "fee_reserve",
"fee_reserve_total", "fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice", "pay_invoice",
"perform_withdraw",
"send_payment_notification",
"service_fee", "service_fee",
"settle_hold_invoice",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
"update_pending_payments", "update_pending_payments",
"update_wallet_balance",
# settings
"check_webpush_settings",
"update_cached_settings",
# users
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"update_user_account", "update_user_account",
"update_user_extensions", "update_user_extensions",
"update_wallet_balance", # websockets
"websocket_manager", "websocket_manager",
"websocket_updater", "websocket_updater",
] ]
-56
View File
@@ -1,56 +0,0 @@
import base64
import io
from uuid import uuid4
from fastapi import UploadFile
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
f"Max upload count of {settings.lnbits_max_assets_per_user} exceeded."
)
contents = await file.read()
if len(contents) > settings.lnbits_max_asset_size_mb * 1024 * 1024:
raise ValueError(
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=file.content_type,
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
data=contents,
)
await create_asset(asset)
return asset
+14 -19
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import importlib import importlib
from typing import Optional
from loguru import logger from loguru import logger
@@ -16,30 +17,25 @@ from lnbits.core.crud.extensions import (
update_installed_extension, update_installed_extension,
) )
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension( async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info: InstallableExtension, skip_download: bool | None = False
) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta() ext_info.meta = ext_info.meta or ExtensionMeta()
if ( if ext_info.meta.installed_release:
ext_info.meta.installed_release assert (
and not ext_info.meta.installed_release.is_version_compatible ext_info.meta.installed_release.is_version_compatible
): ), "Incompatible extension version"
raise ValueError("Incompatible extension version")
installed_ext = await get_installed_extension(ext_info.id) installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments ext_info.meta.payments = installed_ext.meta.payments
if not skip_download: await ext_info.download_archive()
await ext_info.download_archive()
ext_info.extract_archive() ext_info.extract_archive()
@@ -77,13 +73,11 @@ async def uninstall_extension(ext_id: str):
async def activate_extension(ext: Extension): async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext) core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True) await update_installed_extension_state(ext_id=ext.code, active=True)
await start_extension_background_work(ext.code)
async def deactivate_extension(ext_id: str): async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id) settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False) await update_installed_extension_state(ext_id=ext_id, active=False)
await stop_extension_background_work(ext_id)
async def stop_extension_background_work(ext_id: str) -> bool: async def stop_extension_background_work(ext_id: str) -> bool:
@@ -99,8 +93,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
old_module = importlib.import_module(ext.module_name) old_module = importlib.import_module(ext.module_name)
stop_fn_name = f"{ext_id}_stop" stop_fn_name = f"{ext_id}_stop"
if not hasattr(old_module, stop_fn_name): assert hasattr(
raise ValueError(f"No stop function found for '{ext.module_name}'.") old_module, stop_fn_name
), f"No stop function found for '{ext.module_name}'."
stop_fn = getattr(old_module, stop_fn_name) stop_fn = getattr(old_module, stop_fn_name)
if stop_fn: if stop_fn:
@@ -150,9 +145,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions( async def get_valid_extensions(
include_deactivated: bool | None = True, conn: Connection | None = None include_deactivated: Optional[bool] = True,
) -> list[Extension]: ) -> list[Extension]:
installed_extensions = await get_installed_extensions(conn=conn) installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions] valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated: if include_deactivated:
@@ -169,8 +164,8 @@ async def get_valid_extensions(
async def get_valid_extension( async def get_valid_extension(
ext_id: str, include_deactivated: bool | None = True ext_id: str, include_deactivated: Optional[bool] = True
) -> Extension | None: ) -> Optional[Extension]:
ext = await get_installed_extension(ext_id) ext = await get_installed_extension(ext_id)
if not ext: if not ext:
return None return None
-543
View File
@@ -1,543 +0,0 @@
import asyncio
import json
import os
import shutil
import zipfile
from hashlib import sha256
from pathlib import Path
from time import time
from uuid import uuid4
import shortuuid
from jinja2 import Environment, FileSystemLoader
from loguru import logger
from lnbits.core.models.extensions import ExtensionRelease, InstallableExtension
from lnbits.core.models.extensions_builder import DataField, ExtensionData
from lnbits.db import dict_to_model
from lnbits.helpers import (
camel_to_snake,
camel_to_words,
download_url,
lowercase_first_letter,
)
from lnbits.settings import settings
py_files = [
"__init__.py",
"models.py",
"migrations.py",
"views_api.py",
"crud.py",
"views.py",
"tasks.py",
"services.py",
]
remove_line_marker = "{remove_line_marker}}"
ui_table_columns = [
DataField(
name="updated_at",
type="datetime",
label="Updated At",
hint="Timestamp of the last update",
optional=False,
editable=False,
searchable=False,
sortable=True,
),
DataField(
name="id",
type="str",
label="ID",
hint="Unique identifier",
optional=False,
editable=False,
searchable=False,
sortable=True,
),
]
excluded_dirs = {"./.", "./__pycache__", "./node_modules", "./transform"}
async def build_extension_from_data(
data: ExtensionData, stub_ext_id: str, working_dir_name: str | None = None
):
release = await _get_extension_stub_release(stub_ext_id, data.stub_version)
release.hash = sha256(uuid4().hex.encode("utf-8")).hexdigest()
release.icon = f"/{data.id}/static/image/{data.id}.png"
release.is_github_release = False
await _fetch_extension_builder_stub(stub_ext_id, release)
build_dir = _copy_ext_stub_to_build_dir(
stub_ext_id=stub_ext_id,
stub_version=release.version,
new_ext_id=data.id,
working_dir_name=working_dir_name,
)
_transform_extension_builder_stub(data, build_dir)
_export_extension_data_json(data, build_dir)
return release, build_dir
def clean_extension_builder_data() -> None:
working_dir = Path(settings.extension_builder_working_dir_path)
if working_dir.is_dir():
shutil.rmtree(working_dir, True)
working_dir.mkdir(parents=True, exist_ok=True)
def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
_replace_jinja_placeholders(data, extension_dir)
_rename_extension_builder_stub(data, extension_dir)
def _export_extension_data_json(data: ExtensionData, build_dir: Path):
json.dump(
data.dict(),
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
indent=4,
)
async def _get_extension_stub_release(
stub_ext_id: str, stub_version: str | None = None
) -> ExtensionRelease:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{stub_version}")
cache_dir.mkdir(parents=True, exist_ok=True)
release_cache_file = Path(cache_dir, "release.json")
if stub_version:
cached_release = _load_extension_stub_release_from_cache(
stub_ext_id, stub_version
)
if cached_release:
logger.debug(f"Loading release from cache {stub_ext_id} ({stub_version}).")
return cached_release
releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(stub_ext_id)
)
release = next((r for r in releases if r.version == stub_version), None)
if not release and len(releases) > 0:
release = releases[0]
if not release:
raise ValueError(f"Release {stub_ext_id} ({stub_version}) not found.")
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
with open(release_cache_file, "w", encoding="utf-8") as f:
f.write(json.dumps(release.dict(), indent=4))
return release
def _load_extension_stub_release_from_cache(
stub_ext_id: str, stub_version: str
) -> ExtensionRelease | None:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{stub_version}")
release_cache_file = Path(cache_dir, "release.json")
if release_cache_file.is_file():
with open(release_cache_file, encoding="utf-8") as f:
return dict_to_model(json.load(f), ExtensionRelease)
return None
async def _fetch_extension_builder_stub(
stub_ext_id: str, release: ExtensionRelease
) -> Path:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{release.version}")
cache_dir.mkdir(parents=True, exist_ok=True)
stub_ext_zip_path = Path(cache_dir, release.version + ".zip")
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
if not stub_ext_zip_path.is_file():
await asyncio.to_thread(download_url, release.archive_url, stub_ext_zip_path)
shutil.rmtree(ext_stub_cache_dir, True)
if not ext_stub_cache_dir.is_dir():
tmp_dir = Path(cache_dir, "tmp")
shutil.rmtree(tmp_dir, True)
tmp_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(stub_ext_zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir = Path(tmp_dir, os.listdir(tmp_dir)[0])
shutil.copytree(generated_dir, Path(ext_stub_cache_dir))
shutil.rmtree(tmp_dir, True)
return ext_stub_cache_dir
def _copy_ext_stub_to_build_dir(
stub_ext_id: str,
stub_version: str,
new_ext_id: str,
working_dir_name: str | None = None,
) -> Path:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{stub_version}")
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
if not ext_stub_cache_dir.is_dir():
raise ValueError(
f"Extension stub cache dir not found: {stub_ext_id} ({stub_version})"
)
working_dir_name = working_dir_name or f"ext-{int(time())}-{shortuuid.uuid()}"
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data)
for py_file in py_files:
template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data}
)
embeded_index_js = rederer
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
owner_inputs = _fields_to_html_input(
[f for f in data.owner_data.fields if f.editable],
"ownerDataFormDialog.data",
ext_stub_dir,
)
client_inputs = _fields_to_html_input(
[f for f in data.client_data.fields if f.editable],
"clientDataFormDialog.data",
ext_stub_dir,
)
settings_inputs = _fields_to_html_input(
[f for f in data.settings_data.fields if f.editable],
"settingsFormDialog.data",
ext_stub_dir,
)
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
rederer = _render_file(
template_path,
{
"embeded_index_js": embeded_index_js,
"extension_builder_stub_owner_inputs": owner_inputs,
"extension_builder_stub_settings_inputs": settings_inputs,
"extension_builder_stub_client_inputs": client_inputs,
"preview": data.preview_action,
"cancel_comment": remove_line_marker,
**parsed_data,
},
)
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
public_client_inputs = _fields_to_html_input(
[
f
for f in data.client_data.fields
if f.name in data.public_page.client_data_fields.public_inputs
],
"publicClientData",
ext_stub_dir,
)
public_template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
template_path = public_template_path.as_posix()
if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True)
else:
rederer = _render_file(
template_path,
{
"extension_builder_stub_public_client_inputs": public_client_inputs,
"preview": data.preview_action,
**data.public_page.action_fields.dict(),
"cancel_comment": remove_line_marker,
},
)
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
def zip_directory(source_dir, zip_path):
"""
Zips the contents of a directory (including subdirectories and files).
Parameters:
- source_dir (str): The path of the directory to zip.
- zip_path (str): The path where the .zip file will be saved.
"""
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, _, files in os.walk(source_dir):
if _is_excluded_dir(root):
continue
for file in files:
full_path = os.path.join(root, file)
# Add file with a relative path inside the zip
relative_path = os.path.relpath(full_path, start=source_dir)
zipf.write(full_path, arcname=relative_path)
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix()
rename_values = {
"extension_builder_stub_name": data.name,
"extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id,
"OwnerData": data.owner_data.name,
"ownerData": lowercase_first_letter(data.owner_data.name),
"Owner Data": camel_to_words(data.owner_data.name),
"owner data": camel_to_words(data.owner_data.name).lower(),
"owner_data": camel_to_snake(data.owner_data.name),
"ClientData": data.client_data.name,
"clientData": lowercase_first_letter(data.client_data.name),
"Client Data": camel_to_words(data.client_data.name),
"client data": camel_to_words(data.client_data.name).lower(),
"client_data": camel_to_snake(data.client_data.name),
}
for old_text, new_text in rename_values.items():
_replace_text_in_files(
directory=extension_dir_path,
old_text=old_text,
new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
)
_rename_files_and_dirs_in_directory(
directory=extension_dir_path,
old_text="extension_builder_stub",
new_text=data.id,
)
_rename_files_and_dirs_in_directory(
directory=extension_dir_path,
old_text="owner_data",
new_text=camel_to_snake(data.owner_data.name),
)
def _replace_text_in_files(
directory: str,
old_text: str,
new_text: str,
file_extensions: list[str] | None = None,
):
"""
Recursively replaces text in all files under the given directory.
"""
for root, _, files in os.walk(directory):
if _is_excluded_dir(root):
continue
for filename in files:
if file_extensions:
if not any(filename.endswith(ext) for ext in file_extensions):
continue
file_path = os.path.join(root, filename)
try:
with open(file_path, encoding="utf-8") as f:
content = f.read()
if old_text in content:
new_content = content.replace(old_text, new_text)
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_content)
logger.trace(f"Updated: {file_path}")
except (UnicodeDecodeError, PermissionError, FileNotFoundError) as e:
logger.debug(f"Skipped {file_path}: {e}")
def _render_file(template_path: str, data: dict) -> str:
# Extract directory and file name
template_dir = os.path.dirname(template_path)
template_file = os.path.basename(template_path)
# Create Jinja environment
# env = Environment(loader=FileSystemLoader(template_dir))
env = _jinja_env(template_dir)
template = env.get_template(template_file)
# Render the template with data
return template.render(**data)
def _jinja_env(template_dir: str) -> Environment:
return Environment(
loader=FileSystemLoader(template_dir),
variable_start_string="<<",
variable_end_string=">>",
block_start_string="<%",
block_end_string="%>",
comment_start_string="<#",
comment_end_string="#>",
autoescape=False,
)
def _parse_extension_data(data: ExtensionData) -> dict:
return {
"owner_data": {
"name": data.owner_data.name,
"editable": data.owner_data.editable,
"js_fields": [
field.field_to_js()
for field in data.owner_data.fields
if field.editable
],
"search_fields": [
camel_to_snake(field.name)
for field in data.owner_data.fields
if field.searchable
],
"ui_table_columns": [
field.field_to_ui_table_column()
for field in data.owner_data.fields + ui_table_columns
if field.sortable
],
"ui_mock_data": [
json.dumps(
{
field.name: field.field_mock_value(index=index)
for field in data.owner_data.fields + ui_table_columns
}
)
for index in range(1, 5)
],
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
},
"client_data": {
"name": data.client_data.name,
"editable": data.client_data.editable,
"search_fields": [
camel_to_snake(field.name)
for field in data.client_data.fields
if field.searchable
],
"ui_table_columns": [
field.field_to_ui_table_column()
for field in data.client_data.fields + ui_table_columns
if field.sortable
],
"ui_mock_data": [
json.dumps(
{
field.name: field.field_mock_value(index=index)
for field in data.client_data.fields + ui_table_columns
}
)
for index in range(1, 7)
],
"db_fields": [field.field_to_db() for field in data.client_data.fields],
"all_fields": [field.field_to_py() for field in data.client_data.fields],
},
"settings_data": {
"enabled": data.settings_data.enabled,
"is_admin_settings_only": data.settings_data.type == "admin",
"db_fields": [field.field_to_db() for field in data.settings_data.fields],
"all_fields": [field.field_to_py() for field in data.settings_data.fields],
},
"public_page": data.public_page,
"cancel_comment": remove_line_marker,
}
def _fields_to_html_input(
fields: list[DataField], model_name: str, ext_stub_dir: Path
) -> str:
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "_input_fields.html"
).as_posix()
rederer = _render_file(
template_path,
{
"fields": fields,
"model_name": model_name,
},
)
return rederer
def _remove_lines_with_string(file_path: str, target: str) -> None:
"""
Removes lines from a file that contain the given target string.
Args:
file_path (str): Path to the file.
target (str): Substring to search for in lines to remove.
"""
with open(file_path, encoding="utf-8") as f:
lines = f.readlines()
filtered_lines = [line for line in lines if target not in line]
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(filtered_lines)
def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
"""
Recursively renames files and directories by replacing part of their names.
"""
# First rename directories (bottom-up) so we don't lose paths while renaming
for root, dirs, files in os.walk(directory, topdown=False):
if _is_excluded_dir(root):
continue
# Rename files
for filename in files:
if old_text in filename:
old_path = os.path.join(root, filename)
new_filename = filename.replace(old_text, new_text)
new_path = os.path.join(root, new_filename)
try:
os.rename(old_path, new_path)
logger.trace(f"Renamed file: {old_path} -> {new_path}")
except Exception as e:
logger.warning(f"Failed to rename file {old_path}: {e}")
# Rename directories
for dirname in dirs:
if old_text in dirname:
old_dir_path = os.path.join(root, dirname)
new_dir_name = dirname.replace(old_text, new_text)
new_dir_path = os.path.join(root, new_dir_name)
try:
os.rename(old_dir_path, new_dir_path)
logger.trace(f"Renamed directory: {old_dir_path} -> {new_dir_path}")
except Exception as e:
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path):
for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir):
return True
return False
-232
View File
@@ -1,232 +0,0 @@
import hashlib
import hmac
import json
import time
import httpx
from loguru import logger
from lnbits.core.crud import get_wallet
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider
from lnbits.settings import settings
async def handle_fiat_payment_confirmation(
payment: Payment, conn: Connection | None = None
):
try:
await _credit_fiat_service_fee_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
try:
await _debit_fiat_service_faucet_wallet(payment, conn=conn)
except Exception as e:
logger.warning(e)
def check_stripe_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Stripe-Signature header is missing.")
raise ValueError("Stripe-Signature header is missing.")
if not secret:
logger.warning("Stripe webhook signing secret is not set.")
raise ValueError("Stripe webhook cannot be verified.")
# Split the Stripe-Signature header
items = dict(i.split("=") for i in sig_header.split(","))
timestamp = int(items["t"])
signature = items["v1"]
# Check timestamp tolerance
if abs(time.time() - timestamp) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
logger.debug(
f"Current time: {time.time()}, "
f"Timestamp: {timestamp}, "
f"Tolerance: {tolerance_seconds} seconds"
)
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = f"{timestamp}.{payload.decode()}"
# Compute HMAC SHA256 using the webhook secret
computed_signature = hmac.new(
key=secret.encode(), msg=signed_payload.encode(), digestmod=hashlib.sha256
).hexdigest()
# Compare signatures using constant time comparison
if hmac.compare_digest(computed_signature, signature) is not True:
logger.warning("Stripe signature verification failed.")
raise ValueError("Stripe signature verification failed.")
async def verify_paypal_webhook(headers, payload: bytes):
"""
Validate PayPal webhook signatures using the PayPal verify API.
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set; skipping verification.")
return
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers; skipping verification.")
return
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
token_resp = await client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(
settings.paypal_client_id or "",
settings.paypal_client_secret or "",
),
)
token_resp.raise_for_status()
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("PayPal token missing in verification flow.")
verify_resp = await client.post(
"/v1/notifications/verify-webhook-signature",
json={
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
"cert_url": required_headers["PAYPAL-CERT-URL"],
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
"webhook_id": webhook_id,
"webhook_event": json.loads(payload.decode()),
},
headers={"Authorization": f"Bearer {access_token}"},
)
verify_resp.raise_for_status()
verification_status = verify_resp.json().get("verification_status")
if verification_status != "SUCCESS":
raise ValueError("PayPal webhook verification failed.")
except Exception as exc:
logger.warning(exc)
raise ValueError("PayPal webhook cannot be verified.") from exc
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
success=False,
message=f"Cconnection test failed: {status.error_message}",
)
return SimpleStatus(
success=True,
message="Connection test successful." f" Balance: {status.balance}.",
)
async def _credit_fiat_service_fee_wallet(
payment: Payment, conn: Connection | None = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
if payment.fee == 0:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_fee_wallet_id:
return
memo = (
f"Service fee for fiat payment of "
f"{abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_fee_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(payment.fee),
memo=memo,
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
async def _debit_fiat_service_faucet_wallet(
payment: Payment, conn: Connection | None = None
):
fiat_provider_name = payment.fiat_provider
if not fiat_provider_name:
return
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return
if not limits.service_faucet_wallet_id:
return
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found."
)
memo = (
f"Faucet payment of {abs(payment.sat)} sats. "
f"Provider: {fiat_provider_name}. "
f"Wallet: '{payment.wallet_id}'."
)
create_payment_model = CreatePayment(
wallet_id=limits.service_faucet_wallet_id,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=-abs(payment.amount),
memo=memo,
extra=payment.extra,
)
await create_payment(
checking_id=f"internal_fiat_{fiat_provider_name}_"
f"faucet_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
+4 -5
View File
@@ -1,7 +1,7 @@
from loguru import logger from loguru import logger
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_admin_notification from lnbits.core.services.notifications import enqueue_notification
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source from lnbits.wallets import get_funding_source, set_funding_source
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
f"Balance delta reached: {status.delta_sats} sats." f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}." f" Switch to void wallet: {use_voidwallet}."
) )
enqueue_admin_notification( enqueue_notification(
NotificationType.watchdog_check, NotificationType.watchdog_check,
{ {
"delta_sats": status.delta_sats, "delta_sats": status.delta_sats,
@@ -70,9 +70,8 @@ async def check_balance_delta_changed():
if settings.latest_balance_delta_sats is None: if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats settings.latest_balance_delta_sats = status.delta_sats
return return
delta_change = abs(status.delta_sats - settings.latest_balance_delta_sats) if status.delta_sats != settings.latest_balance_delta_sats:
if delta_change >= settings.notification_balance_delta_threshold_sats: enqueue_notification(
enqueue_admin_notification(
NotificationType.balance_delta, NotificationType.balance_delta,
{ {
"delta_sats": status.delta_sats, "delta_sats": status.delta_sats,
+147 -156
View File
@@ -1,167 +1,158 @@
from time import time import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
from lnurl import ( import httpx
LnAddress, from fastapi import Depends
Lnurl,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
LnurlSuccessResponse,
LnurlWithdrawResponse,
execute_pay_request,
execute_withdraw,
handle,
)
from loguru import logger from loguru import logger
from lnbits.core.crud import update_wallet from lnbits.db import Connection
from lnbits.core.models import CreateLnurlPayment, Wallet from lnbits.decorators import (
from lnbits.core.models.lnurl import StoredPayLink WalletTypeInfo,
from lnbits.helpers import check_callback_url require_admin_key,
)
from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from .payments import create_invoice
async def perform_withdraw(lnurl: str, payment_request: str) -> None: async def redeem_lnurl_withdraw(
""" wallet_id: str,
Perform an LNURL withdraw to the given LNURL-withdraw link. lnurl_request: str,
:param lnurl: The LNURL-withdraw link. bech32 or lud17 format. memo: Optional[str] = None,
:param payment_request: The BOLT11 payment request to pay. extra: Optional[dict] = None,
:raises LnurlResponseException: If the LNURL-withdraw process fails. wait_seconds: int = 0,
""" conn: Optional[Connection] = None,
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlWithdrawResponse):
raise LnurlResponseException("Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
raise LnurlResponseException(f"Invalid callback URL: {exc!s}") from exc
res2 = await execute_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
if not isinstance(res2, LnurlSuccessResponse):
raise LnurlResponseException("Invalid LNURL-withdraw success response.")
async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None
) -> str:
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
res2 = await execute_pay_request(
res,
msat=amount_msat,
comment=comment,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
return res2.pr
async def fetch_lnurl_pay_request(
data: CreateLnurlPayment, wallet: Wallet | None = None
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
"""
Pay an LNURL payment request.
optional `wallet` is used to store the pay link in the wallet's stored links.
raises `LnurlResponseException` if pay request fails
"""
if not data.res and data.lnurl:
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
data.res = res
if not data.res:
raise LnurlResponseException("No LNURL pay request provided.")
if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
amount = round(data.amount / 1000, 2)
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
amount_msat *= 1000
else:
amount_msat = data.amount
res2 = await execute_pay_request(
data.res,
msat=amount_msat,
comment=data.comment,
user_agent=settings.user_agent,
timeout=10,
)
if wallet:
await store_paylink(data.res, res2, wallet, data.lnurl)
return data.res, res2
async def store_paylink(
res: LnurlPayResponse,
res2: LnurlPayActionResponse,
wallet: Wallet,
lnurl: LnAddress | Lnurl | None = None,
) -> None: ) -> None:
if not lnurl_request:
return None
if res2.disposable is not False: res = {}
return # do not store disposable LNURL pay links
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ") headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
check_callback_url(str(lnurl))
r = await client.get(str(lnurl))
res = r.json()
stored_paylink = None try:
# If we have only a LnurlPayResponse, we can use its lnaddress _, payment_request = await create_invoice(
# because the lnurl is not available. wallet_id=wallet_id,
if not lnurl: amount=int(res["maxWithdrawable"] / 1000),
for _data in res.metadata.list(): memo=memo or res["defaultDescription"] or "",
if _data[0] == "text/identifier": extra=extra,
stored_paylink = StoredPayLink( conn=conn,
lnurl=LnAddress(_data[1]), label=res.metadata.text
)
if not stored_paylink:
logger.warning(
"No lnaddress found in metadata for LNURL pay link. "
"Skipping storage."
)
return # skip if lnaddress not found in metadata
else:
if isinstance(lnurl, Lnurl):
_lnurl = str(lnurl.lud17 or lnurl.bech32)
else:
_lnurl = str(lnurl)
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
# update last_used if its already stored
for pl in wallet.stored_paylinks.links:
if pl.lnurl == stored_paylink.lnurl:
pl.last_used = int(time())
await update_wallet(wallet)
logger.debug(
"Updated last used time for LNURL "
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
)
return
# if not already stored, append it
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
wallet.stored_paylinks.links.append(stored_paylink)
await update_wallet(wallet)
logger.debug(
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
) )
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
check_callback_url(callback)
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
+2 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
from typing import Tuple
import httpx import httpx
from loguru import logger from loguru import logger
@@ -47,7 +48,7 @@ async def send_nostr_dm(
return dm_event.to_dict() return dm_event.to_dict()
async def fetch_nip5_details(identifier: str) -> tuple[str, list[str]]: async def fetch_nip5_details(identifier: str) -> Tuple[str, list[str]]:
identifier, domain = identifier.split("@") identifier, domain = identifier.split("@")
if not identifier or not domain: if not identifier or not domain:
raise ValueError("Invalid NIP5 identifier") raise ValueError("Invalid NIP5 identifier")
+93 -190
View File
@@ -1,10 +1,10 @@
import asyncio import asyncio
import json import json
import smtplib import smtplib
from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from http import HTTPStatus from http import HTTPStatus
from typing import Optional, Tuple
import httpx import httpx
from loguru import logger from loguru import logger
@@ -16,26 +16,23 @@ from lnbits.core.crud import (
get_webpush_subscriptions_for_user, get_webpush_subscriptions_for_user,
mark_webhook_sent, mark_webhook_sent,
) )
from lnbits.core.crud.users import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import Payment, Wallet from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import ( from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES, NOTIFICATION_TEMPLATES,
NotificationMessage, NotificationMessage,
NotificationType, NotificationType,
) )
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue() notifications_queue: asyncio.Queue = asyncio.Queue()
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None: def enqueue_notification(message_type: NotificationType, values: dict) -> None:
if not _is_message_type_enabled(message_type): if not is_message_type_enabled(message_type):
return return
try: try:
notifications_queue.put_nowait( notifications_queue.put_nowait(
@@ -45,125 +42,62 @@ def enqueue_admin_notification(message_type: NotificationType, values: dict) ->
logger.error(f"Error enqueuing notification: {e}") logger.error(f"Error enqueuing notification: {e}")
def enqueue_user_notification( async def process_next_notification():
message_type: NotificationType, values: dict, user_notifications: UserNotifications notification_message: NotificationMessage = await notifications_queue.get()
) -> None:
try:
notifications_queue.put_nowait(
NotificationMessage(
message_type=message_type,
values=values,
user_notifications=user_notifications,
)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification() -> None:
notification_message = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message) message_type, text = _notification_message_to_text(notification_message)
user_notifications = notification_message.user_notifications await send_notification(text, message_type)
if user_notifications:
await send_user_notification(user_notifications, text, message_type)
else:
await send_admin_notification(text, message_type)
async def send_admin_notification(
message: str,
message_type: str | None = None,
) -> None:
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
message,
message_type,
)
async def send_user_notification(
user_notifications: UserNotifications,
message: str,
message_type: str | None = None,
) -> None:
email_address = (
[user_notifications.email_address] if user_notifications.email_address else []
)
nostr_identifiers = (
[user_notifications.nostr_identifier]
if user_notifications.nostr_identifier
else []
)
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
message,
message_type,
)
async def send_notification( async def send_notification(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str, message: str,
message_type: str | None = None, message_type: Optional[str] = None,
) -> None: ) -> None:
try: try:
if telegram_chat_id and settings.is_telegram_notifications_configured(): if settings.lnbits_telegram_notifications_enabled:
await send_telegram_notification(telegram_chat_id, message) await send_telegram_notification(message)
logger.debug(f"Sent telegram notification: {message_type}") logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}") logger.error(f"Error sending telegram notification {message_type}: {e}")
try: try:
if nostr_identifiers and settings.is_nostr_notifications_configured(): if settings.lnbits_nostr_notifications_enabled:
await send_nostr_notifications(nostr_identifiers, message) await send_nostr_notification(message)
logger.debug(f"Sent nostr notification: {message_type}") logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}") logger.error(f"Error sending nostr notification {message_type}: {e}")
try: try:
if email_addresses and settings.lnbits_email_notifications_enabled: if settings.lnbits_email_notifications_enabled:
await send_email_notification(email_addresses, message) await send_email_notification(message)
logger.debug(f"Sent email notification: {message_type}") logger.debug(f"Sent email notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}") logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]: async def send_nostr_notification(message: str) -> dict:
success_sent: list[str] = [] for i in settings.lnbits_nostr_notifications_identifiers:
for identifier in identifiers:
try: try:
await send_nostr_notification(identifier, message) identifier = await fetch_nip5_details(i)
success_sent.append(identifier) user_pubkey = identifier[0]
relays = identifier[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
except Exception as e: except Exception as e:
logger.warning(f"Error notifying identifier {identifier}: {e}") logger.warning(f"Error notifying identifier {i}: {e}")
return success_sent
return {"status": "ok"}
async def send_nostr_notification(identifier: str, message: str): async def send_telegram_notification(message: str) -> dict:
nip5 = await fetch_nip5_details(identifier)
user_pubkey = nip5[0]
relays = nip5[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
async def send_telegram_notification(chat_id: str, message: str) -> dict:
return await send_telegram_message( return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token, settings.lnbits_telegram_notifications_access_token,
chat_id, settings.lnbits_telegram_notifications_chat_id,
message, message,
) )
@@ -178,7 +112,7 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
async def send_email_notification( async def send_email_notification(
to_emails: list[str], message: str, subject: str = "LNbits Notification" message: str, subject: str = "LNbits Notification"
) -> dict: ) -> dict:
if not settings.lnbits_email_notifications_enabled: if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"} return {"status": "error", "message": "Email notifications are disabled"}
@@ -189,7 +123,7 @@ async def send_email_notification(
settings.lnbits_email_notifications_username, settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password, settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email, settings.lnbits_email_notifications_email,
to_emails, settings.lnbits_email_notifications_to_emails,
subject, subject,
message, message,
) )
@@ -229,6 +163,41 @@ async def send_email(
return True return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> Tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment): async def dispatch_webhook(payment: Payment):
""" """
Dispatches the webhook to the webhook url. Dispatches the webhook to the webhook url.
@@ -236,67 +205,48 @@ async def dispatch_webhook(payment: Payment):
logger.debug("sending webhook", payment.webhook) logger.debug("sending webhook", payment.webhook)
if not payment.webhook: if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, "-1") return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try: try:
check_callback_url(payment.webhook) check_callback_url(payment.webhook)
except ValueError as exc: r = await client.post(payment.webhook, json=data, timeout=40)
await mark_webhook_sent(payment.payment_hash, "-1")
logger.warning(f"Invalid webhook URL {payment.webhook}: {exc!s}")
try:
r = await client.post(payment.webhook, json=payment.json(), timeout=40)
r.raise_for_status() r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, str(r.status_code)) await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, str(exc.response.status_code)) await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning( logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} " f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}." f"while requesting {exc.request.url!r}."
) )
except httpx.RequestError: except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, "-1") await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send webhook to {payment.webhook}") logger.warning(f"Could not send webhook to {payment.webhook}")
async def send_payment_notification(wallet: Wallet, payment: Payment): async def send_payment_notification(wallet: Wallet, payment: Payment):
try: try:
await send_ws_payment_notification(wallet, payment) await send_ws_payment_notification(wallet, payment)
for shared in wallet.extra.shared_with:
if not shared.shared_with_wallet_id:
continue
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
if shared_wallet and shared_wallet.can_view_payments:
await send_ws_payment_notification(shared_wallet, payment)
except Exception as e: except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}") logger.error("Error sending websocket payment notification", e)
try: try:
await send_chat_payment_notification(wallet, payment) send_chat_payment_notification(wallet, payment)
except Exception as e: except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}") logger.error("Error sending chat payment notification", e)
try: try:
await send_payment_push_notification(wallet, payment) await send_payment_push_notification(wallet, payment)
except Exception as e: except Exception as e:
logger.error(f"Error sending push payment notification {e!s}") logger.error("Error sending push payment notification", e)
try: if payment.webhook and not payment.webhook_status:
if payment.webhook and not payment.webhook_status: await dispatch_webhook(payment)
await dispatch_webhook(payment)
except Exception as e:
logger.error(f"Error dispatching webhook: {e!s}")
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
try:
create_task(send_payment_notification(wallet, payment))
except Exception as e:
logger.warning(f"Error sending payment notification: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment): async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model # TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json()) # await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here. # TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance # cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it # and send it with the id of the wallet so wallets can subscribe to it
@@ -307,15 +257,16 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
"payment": json.loads(payment.json()), "payment": json.loads(payment.json()),
}, },
) )
await websocket_manager.send(wallet.inkey, payment_notification) await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send(wallet.adminkey, payment_notification) await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send(
payment.payment_hash, await websocket_manager.send_data(
json.dumps({"pending": payment.pending, "status": payment.status}), json.dumps({"pending": payment.pending, "status": payment.status}),
payment.payment_hash,
) )
async def send_chat_payment_notification(wallet: Wallet, payment: Payment): def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat) amount_sats = abs(payment.sat)
values: dict = { values: dict = {
"wallet_id": wallet.id, "wallet_id": wallet.id,
@@ -330,23 +281,10 @@ async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
if payment.is_out: if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats: if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_admin_notification(NotificationType.outgoing_payment, values) enqueue_notification(NotificationType.outgoing_payment, values)
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats: else:
enqueue_admin_notification(NotificationType.incoming_payment, values) if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
user = await get_user(wallet.user)
user_notifications = user.extra.notifications if user else None
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
out_limit = user_notifications.outgoing_payments_sats
in_limit = user_notifications.incoming_payments_sats
if payment.is_out and (amount_sats >= out_limit):
enqueue_user_notification(
NotificationType.outgoing_payment, values, user_notifications
)
elif amount_sats >= in_limit:
enqueue_user_notification(
NotificationType.incoming_payment, values, user_notifications
)
async def send_payment_push_notification(wallet: Wallet, payment: Payment): async def send_payment_push_notification(wallet: Wallet, payment: Payment):
@@ -390,38 +328,3 @@ async def send_push_notification(subscription, title, body, url=""):
f"failed sending push notification: " f"failed sending push notification: "
f"{e.response.text if e.response else e}" f"{e.response.text if e.response else e}"
) )
def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_threshold_sats > 0
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
+121 -459
View File
@@ -1,30 +1,25 @@
import asyncio import asyncio
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from bolt11 import Bolt11, MilliSatoshi, Tags from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode from bolt11 import encode as bolt11_encode
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
from lnurl import execute_withdraw as lnurl_withdraw
from loguru import logger from loguru import logger
from lnbits.core.crud.payments import get_daily_stats from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put from lnbits.tasks import create_task
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
InvoiceResponse,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
@@ -47,216 +42,64 @@ from ..models import (
PaymentState, PaymentState,
Wallet, Wallet,
) )
from .notifications import send_payment_notification_in_background from .notifications import send_payment_notification
payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {}
async def pay_invoice( async def pay_invoice(
*, *,
wallet_id: str, wallet_id: str,
payment_request: str, payment_request: str,
max_sat: int | None = None, max_sat: Optional[int] = None,
extra: dict | None = None, extra: Optional[dict] = None,
description: str = "", description: str = "",
tag: str = "", tag: str = "",
labels: list[str] | None = None, conn: Optional[Connection] = None,
conn: Connection | None = None,
) -> Payment: ) -> Payment:
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed") raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat) invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
if not invoice.amount_msat:
raise ValueError("Missig invoice amount.")
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
amount_msat = invoice.amount_msat amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn) wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
if not wallet.can_send_payments:
raise PaymentError(
"Wallet does not have permission to pay invoices.",
status="failed",
)
if await is_internal_status_success(invoice.payment_hash, new_conn): if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed") raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra) _, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet_id,
bolt11=payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount_msat=-amount_msat, amount_msat=-amount_msat,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
labels=labels,
) )
payment = await _pay_invoice(wallet, create_payment_model, conn)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
payment = await _pay_invoice( await _credit_service_fee_wallet(payment, new_conn)
wallet.source_wallet_id, create_payment_model, conn=new_conn
)
await _credit_service_fee_wallet(wallet, payment, conn=new_conn) return payment
return payment
async def create_payment_request(
wallet_id: str, invoice_data: CreateInvoice
) -> Payment:
"""
Create a lightning invoice or a fiat payment request.
"""
if invoice_data.fiat_provider:
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Connection | None = None
) -> Payment:
fiat_provider_name = invoice_data.fiat_provider
if not fiat_provider_name:
raise ValueError("Fiat provider is required for fiat invoices.")
if not settings.is_fiat_provider_enabled(fiat_provider_name):
raise ValueError(
f"Fiat provider '{fiat_provider_name}' is not enabled.",
)
if invoice_data.unit == "sat":
raise ValueError("Fiat provider cannot be used with satoshis.")
amount_sat = await fiat_amount_as_satoshis(invoice_data.amount, invoice_data.unit)
await _check_fiat_invoice_limits(amount_sat, fiat_provider_name, conn)
invoice_data.internal = True # use FakeWallet for fiat invoices
if not invoice_data.memo:
invoice_data.memo = settings.lnbits_site_title + f" ({fiat_provider_name})"
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
if not fiat_provider:
raise InvoiceError("No fiat provider found.", status="failed")
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
currency=invoice_data.unit,
memo=invoice_data.memo,
extra=invoice_data.extra or {},
)
if fiat_invoice.failed:
logger.warning(fiat_invoice.error_message)
internal_payment.status = PaymentState.FAILED
await update_payment(internal_payment, conn=conn)
raise ValueError(
f"Cannot create payment request for '{fiat_provider_name}'.",
)
internal_payment.fee = -abs(
service_fee_fiat(internal_payment.msat, fiat_provider_name)
)
internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# todo: move to payent
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = (
f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
)
await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment.checking_id = new_checking_id
return internal_payment
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = None
unhashed_description = None
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 as exc:
raise ValueError(
"'description_hash' must be a valid hex string"
) from exc
if data.unhashed_description:
try:
unhashed_description = bytes.fromhex(data.unhashed_description)
except ValueError as exc:
raise ValueError(
"'unhashed_description' must be a valid hex string",
) from exc
# do not save memo if description_hash or unhashed_description is set
memo = ""
async with db.connect() as conn:
payment = 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,
payment_hash=data.payment_hash,
labels=data.labels,
conn=conn,
)
if data.lnurl_withdraw:
try:
check_callback_url(data.lnurl_withdraw.callback)
res = await lnurl_withdraw(
data.lnurl_withdraw,
payment.bolt11,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res, LnurlErrorResponse):
payment.extra["lnurl_response"] = res.reason
payment.status = "failed"
elif isinstance(res, LnurlSuccessResponse):
payment.extra["lnurl_response"] = True
payment.status = "success"
except Exception as exc:
payment.extra["lnurl_response"] = str(exc)
payment.status = "failed"
# updating to payment here would run into a race condition
# with the payment listeners and they will overwrite each other
return payment
async def create_invoice( async def create_invoice(
*, *,
wallet_id: str, wallet_id: str,
amount: float, amount: float,
currency: str | None = "sat", currency: Optional[str] = "sat",
memo: str, memo: str,
description_hash: bytes | None = None, description_hash: Optional[bytes] = None,
unhashed_description: bytes | None = None, unhashed_description: Optional[bytes] = None,
expiry: int | None = None, expiry: Optional[int] = None,
extra: dict | None = None, extra: Optional[dict] = None,
webhook: str | None = None, webhook: Optional[str] = None,
internal: bool | None = False, internal: Optional[bool] = False,
payment_hash: str | None = None, conn: Optional[Connection] = None,
labels: list[str] | None = None,
conn: Connection | None = None,
) -> Payment: ) -> Payment:
if not amount > 0: if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed") raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -265,12 +108,6 @@ async def create_invoice(
if not user_wallet: if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed") raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
if not user_wallet.can_receive_payments:
raise InvoiceError(
"Wallet does not have permission to create invoices.",
status="failed",
)
invoice_memo = None if description_hash else memo[:640] invoice_memo = None if description_hash else memo[:640]
# use the fake wallet if the invoice is for internal use only # use the fake wallet if the invoice is for internal use only
@@ -295,55 +132,38 @@ async def create_invoice(
status="failed", status="failed",
) )
if payment_hash: (
try: ok,
invoice_response = await funding_source.create_hold_invoice( checking_id,
amount=amount_sat, payment_request,
memo=invoice_memo, error_message,
payment_hash=payment_hash, ) = await funding_source.create_invoice(
description_hash=description_hash, amount=amount_sat,
) memo=invoice_memo,
extra["hold_invoice"] = True description_hash=description_hash,
except UnsupportedError as exc: unhashed_description=unhashed_description,
raise InvoiceError( expiry=expiry or settings.lightning_invoice_expiry,
"Hold invoices are not supported by the funding source.", )
status="failed", if not ok or not payment_request or not checking_id:
) from exc
else:
invoice_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if (
not invoice_response.ok
or not invoice_response.payment_request
or not invoice_response.checking_id
):
raise InvoiceError( raise InvoiceError(
message=invoice_response.error_message or "unexpected backend error.", error_message or "unexpected backend error.", status="pending"
status="pending",
) )
invoice = bolt11_decode(invoice_response.payment_request)
invoice = bolt11_decode(payment_request)
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=user_wallet.source_wallet_id, wallet_id=wallet_id,
bolt11=invoice_response.payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
preimage=invoice_response.preimage,
amount_msat=amount_sat * 1000, amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
webhook=webhook, webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
) )
payment = await create_payment( payment = await create_payment(
checking_id=invoice_response.checking_id, checking_id=checking_id,
data=create_payment_model, data=create_payment_model,
conn=conn, conn=conn,
) )
@@ -361,47 +181,17 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment) await update_pending_payment(payment)
async def update_pending_payment( async def update_pending_payment(payment: Payment) -> bool:
payment: Payment, conn: Connection | None = None
) -> Payment:
status = await payment.check_status() status = await payment.check_status()
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment)
elif status.success: return True
payment = await update_payment_success_status(payment, status, conn=conn) if status.success:
return payment payment.status = PaymentState.SUCCESS
await update_payment(payment)
return True
async def check_pending_payments(): return False
"""
check_pending_payments is called during startup to check for pending payments with
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
return
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
exclude_uncheckable=True,
)
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
payment = await update_pending_payment(payment)
prefix = f"payment ({i+1} / {count})"
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int: def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
@@ -409,7 +199,6 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
def fee_reserve(amount_msat: int, internal: bool = False) -> int: def fee_reserve(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
return settings.fee_reserve(amount_msat, internal) return settings.fee_reserve(amount_msat, internal)
@@ -429,30 +218,10 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
return 0 return 0
def service_fee_fiat(amount_msat: int, fiat_provider_name: str) -> int:
"""
Calculate the service fee for a fiat provider based on the amount in msat.
Return the fee in msat.
"""
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
return 0
amount_msat = abs(amount_msat)
fee_max = limits.service_max_fee_sats * 1000
if not limits.service_fee_wallet_id:
return 0
fee_percentage = int(amount_msat / 100 * limits.service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
async def update_wallet_balance( async def update_wallet_balance(
wallet: Wallet, wallet: Wallet,
amount: int, amount: int,
conn: Connection | None = None, conn: Optional[Connection] = None,
): ):
if amount == 0: if amount == 0:
raise ValueError("Amount cannot be 0.") raise ValueError("Amount cannot be 0.")
@@ -480,7 +249,7 @@ async def update_wallet_balance(
await create_payment( await create_payment(
checking_id=f"internal_{payment_hash}", checking_id=f"internal_{payment_hash}",
data=CreatePayment( data=CreatePayment(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.id,
bolt11=bolt11, bolt11=bolt11,
payment_hash=payment_hash, payment_hash=payment_hash,
amount_msat=amount * 1000, amount_msat=amount * 1000,
@@ -499,7 +268,7 @@ async def update_wallet_balance(
raise ValueError("Balance change failed, amount exceeds maximum balance.") raise ValueError("Balance change failed, amount exceeds maximum balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment = await create_invoice( payment = await create_invoice(
wallet_id=wallet.source_wallet_id, wallet_id=wallet.id,
amount=amount, amount=amount,
memo="Admin credit", memo="Admin credit",
internal=True, internal=True,
@@ -507,18 +276,21 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
await internal_invoice_queue_put(payment.checking_id) # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def check_wallet_limits( async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Connection | None = None wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
): ):
await check_time_limit_between_transactions(wallet_id, conn) await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn) await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions( async def check_time_limit_between_transactions(
wallet_id: str, conn: Connection | None = None wallet_id: str, conn: Optional[Connection] = None
): ):
limit = settings.lnbits_wallet_limit_secs_between_trans limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0: if not limit or limit <= 0:
@@ -538,7 +310,7 @@ async def check_time_limit_between_transactions(
async def check_wallet_daily_withdraw_limit( async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Connection | None = None wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
): ):
limit = settings.lnbits_wallet_limit_daily_max_withdraw limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit: if not limit:
@@ -571,8 +343,8 @@ async def check_wallet_daily_withdraw_limit(
async def calculate_fiat_amounts( async def calculate_fiat_amounts(
amount: float, amount: float,
wallet: Wallet, wallet: Wallet,
currency: str | None = None, currency: Optional[str] = None,
extra: dict | None = None, extra: Optional[dict] = None,
) -> tuple[int, dict]: ) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {} fiat_amounts: dict = extra or {}
@@ -607,9 +379,9 @@ async def calculate_fiat_amounts(
async def check_transaction_status( async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Connection | None = None wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus: ) -> PaymentStatus:
payment: Payment | None = await get_wallet_payment( payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn wallet_id, payment_hash, conn=conn
) )
if not payment: if not payment:
@@ -623,9 +395,8 @@ async def check_transaction_status(
async def get_payments_daily_stats( async def get_payments_daily_stats(
filters: Filters[PaymentFilters], filters: Filters[PaymentFilters],
user_id: str | None = None,
) -> list[PaymentDailyStats]: ) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters, user_id=user_id) data_in, data_out = await get_daily_stats(filters)
balance_total: float = 0 balance_total: float = 0
_none = PaymentDailyStats(date=datetime.now(timezone.utc)) _none = PaymentDailyStats(date=datetime.now(timezone.utc))
@@ -638,16 +409,13 @@ async def get_payments_daily_stats(
data: list[PaymentDailyStats] = [] data: list[PaymentDailyStats] = []
def _tz(dt: datetime) -> datetime: start_date = min(data_in[0].date, data_out[0].date)
return dt.replace(tzinfo=timezone.utc) end_date = max(data_in[-1].date, data_out[-1].date)
start_date = min(_tz(data_in[0].date), _tz(data_out[0].date))
end_date = max(_tz(data_in[-1].date), _tz(data_out[-1].date))
delta = timedelta(days=1) delta = timedelta(days=1)
while start_date <= end_date: while start_date <= end_date:
data_in_point = next((x for x in data_in if _tz(x.date) == start_date), _none) data_in_point = next((x for x in data_in if x.date == start_date), _none)
data_out_point = next((x for x in data_out if _tz(x.date) == start_date), _none) data_out_point = next((x for x in data_out if x.date == start_date), _none)
balance_total += data_in_point.balance + data_out_point.balance balance_total += data_in_point.balance + data_out_point.balance
data.append( data.append(
@@ -669,34 +437,18 @@ async def get_payments_daily_stats(
return data return data
async def _pay_invoice( async def _pay_invoice(wallet, create_payment_model, conn):
wallet_id: str, payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
create_payment_model: CreatePayment, if not payment:
conn: Connection | None = None, payment = await _pay_external_invoice(wallet, create_payment_model, conn)
): return payment
async with payment_lock:
if wallet_id not in wallets_payments_lock:
wallets_payments_lock[wallet_id] = asyncio.Lock()
async with wallets_payments_lock[wallet_id]:
# get the wallet again to make sure we have the latest balance
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(
f"Could not fetch wallet '{wallet_id}'.", status="failed"
)
payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
if not payment:
payment = await _pay_external_invoice(wallet, create_payment_model, conn)
return payment
async def _pay_internal_invoice( async def _pay_internal_invoice(
wallet: Wallet, wallet: Wallet,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Payment | None: ) -> Optional[Payment]:
""" """
Pay an internal payment. Pay an internal payment.
returns None if the payment is not internal. returns None if the payment is not internal.
@@ -706,7 +458,6 @@ async def _pay_internal_invoice(
internal_payment = await check_internal( internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn create_payment_model.payment_hash, conn=conn
) )
if not internal_payment: if not internal_payment:
return None return None
@@ -715,7 +466,6 @@ async def _pay_internal_invoice(
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn internal_payment.checking_id, incoming=True, conn=conn
) )
if not internal_invoice: if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed") raise PaymentError("Internal payment not found.", status="failed")
@@ -726,18 +476,14 @@ async def _pay_internal_invoice(
): ):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed") raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=True) fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat) create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat: if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed") raise PaymentError("Insufficient balance.", status="failed")
# release the preimage
create_payment_model.preimage = internal_invoice.preimage
internal_id = f"internal_{create_payment_model.payment_hash}" internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment( payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
data=create_payment_model, data=create_payment_model,
@@ -752,7 +498,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
send_payment_notification_in_background(wallet, payment) await send_payment_notification(wallet, payment)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@@ -766,15 +512,13 @@ async def _pay_internal_invoice(
async def _pay_external_invoice( async def _pay_external_invoice(
wallet: Wallet, wallet: Wallet,
create_payment_model: CreatePayment, create_payment_model: CreatePayment,
conn: Connection | None = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
checking_id = create_payment_model.payment_hash checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat amount_msat = create_payment_model.amount_msat
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False) fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat):
raise PaymentError("Insufficient balance.", status="failed")
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat: if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError( raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}" f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
@@ -794,55 +538,57 @@ async def _pay_external_invoice(
) )
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
task = create_task( task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat) _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
# make sure a hold invoice or deferred payment is not blocking the server # make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try: try:
payment_response = await asyncio.wait_for(task, timeout=wait_time) wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
payment_response = await asyncio.wait_for(task, wait_time)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# return pending payment on timeout # return pending payment on timeout
logger.debug( logger.debug(f"payment timeout, {checking_id} is still pending")
f"payment timeout after {wait_time}s, {checking_id} is still pending"
)
return payment return payment
# payment failed if payment_response.checking_id and payment_response.checking_id != checking_id:
if ( logger.warning(
payment_response.checking_id is None f"backend sent unexpected checking_id (expected: {checking_id} got:"
or payment_response.ok is False f" {payment_response.checking_id})"
or payment_response.checking_id != checking_id )
): if payment_response.checking_id and payment_response.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {checking_id}")
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
message = payment_response.error_message or "without an error message." raise PaymentError(
raise PaymentError(f"Payment failed: {message}", status="failed") f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
if payment_response.success: status="failed",
payment = await update_payment_success_status( )
payment, payment_response, conn=conn else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
) )
send_payment_notification_in_background(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
return payment
async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Connection | None = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
await update_payment(payment, conn=conn)
return payment return payment
@@ -859,7 +605,7 @@ async def _fundingsource_pay_invoice(
async def _verify_external_payment( async def _verify_external_payment(
payment: Payment, conn: Connection | None = None payment: Payment, conn: Optional[Connection] = None
) -> Payment: ) -> Payment:
# fail on pending payments # fail on pending payments
if payment.pending: if payment.pending:
@@ -876,7 +622,8 @@ async def _verify_external_payment(
if status.success: if status.success:
# payment was successful on the fundingsource # payment was successful on the fundingsource
await update_payment_success_status(payment, status, conn=conn) payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError( raise PaymentError(
"Failed payment was already paid on the fundingsource.", "Failed payment was already paid on the fundingsource.",
status="success", status="success",
@@ -890,7 +637,7 @@ async def _check_wallet_for_payment(
wallet_id: str, wallet_id: str,
tag: str, tag: str,
amount_msat: int, amount_msat: int,
conn: Connection | None = None, conn: Optional[Connection] = None,
): ):
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id, conn=conn)
if not wallet: if not wallet:
@@ -906,7 +653,7 @@ async def _check_wallet_for_payment(
def _validate_payment_request( def _validate_payment_request(
payment_request: str, max_sat: int | None = None payment_request: str, max_sat: Optional[int] = None
) -> Bolt11: ) -> Bolt11:
try: try:
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
@@ -929,22 +676,18 @@ def _validate_payment_request(
async def _credit_service_fee_wallet( async def _credit_service_fee_wallet(
wallet: Wallet, payment: Payment, conn: Connection | None = None payment: Payment, conn: Optional[Connection] = None
): ):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal) service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat: if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return return
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11, bolt11=payment.bolt11,
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat), amount_msat=abs(service_fee_msat),
memo=memo, memo="Service fee",
) )
await create_payment( await create_payment(
checking_id=f"service_fee_{payment.payment_hash}", checking_id=f"service_fee_{payment.payment_hash}",
@@ -952,84 +695,3 @@ async def _credit_service_fee_wallet(
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
conn=conn, conn=conn,
) )
async def _check_fiat_invoice_limits(
amount_sat: int, fiat_provider_name: str, conn: Connection | None = None
):
limits = settings.get_fiat_provider_limits(fiat_provider_name)
if not limits:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' does not have limits configured.",
)
min_amount_sat = limits.service_min_amount_sats
if min_amount_sat and (amount_sat < min_amount_sat):
raise ValueError(
f"Minimum amount is {min_amount_sat} " f"sats for '{fiat_provider_name}'.",
)
max_amount_sats = limits.service_max_amount_sats
if max_amount_sats and (amount_sat > max_amount_sats):
raise ValueError(
f"Maximum amount is {max_amount_sats} " f"sats for '{fiat_provider_name}'.",
)
if limits.service_max_fee_sats > 0 or limits.service_fee_percent > 0:
if not limits.service_fee_wallet_id:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet missing.",
)
fees_wallet = await get_wallet(limits.service_fee_wallet_id, conn=conn)
if not fees_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' service fee wallet not found.",
)
if limits.service_faucet_wallet_id:
faucet_wallet = await get_wallet(limits.service_faucet_wallet_id, conn=conn)
if not faucet_wallet:
raise ValueError(
f"Fiat provider '{fiat_provider_name}' faucet wallet not found.",
)
if faucet_wallet.balance < amount_sat:
raise ValueError(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
if verify_preimage(preimage, payment.payment_hash) is False:
raise InvoiceError("Invalid preimage.", status="failed")
funding_source = get_funding_source()
response = await funding_source.settle_hold_invoice(preimage=preimage)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.preimage = preimage
payment.extra["hold_invoice_settled"] = True
await update_payment(payment)
return response
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
funding_source = get_funding_source()
response = await funding_source.cancel_hold_invoice(
payment_hash=payment.payment_hash
)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.status = PaymentState.FAILED
payment.extra["hold_invoice_cancelled"] = True
await update_payment(payment)
return response
+1 -2
View File
@@ -19,8 +19,7 @@ async def check_webpush_settings():
vapid = Vapid() vapid = Vapid()
vapid.generate_keys() vapid.generate_keys()
privkey = vapid.private_pem() privkey = vapid.private_pem()
if not vapid.public_key: assert vapid.public_key, "VAPID public key does not exist"
raise ValueError("VAPID public key does not exist")
pubkey = b64urlencode( pubkey = b64urlencode(
vapid.public_key.public_bytes( vapid.public_key.public_bytes(
serialization.Encoding.X962, serialization.Encoding.X962,
+32 -40
View File
@@ -1,11 +1,10 @@
from pathlib import Path from pathlib import Path
from typing import Optional
from uuid import uuid4 from uuid import uuid4
from loguru import logger from loguru import logger
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension from lnbits.core.models.extensions import UserExtension
from lnbits.db import Connection
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
SuperSettings, SuperSettings,
@@ -38,7 +37,7 @@ from .settings import update_cached_settings
async def create_user_account( async def create_user_account(
account: Account | None = None, wallet_name: str | None = None account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User: ) -> User:
if not settings.new_accounts_allowed: if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.") raise ValueError("Account creation is disabled.")
@@ -47,48 +46,41 @@ async def create_user_account(
async def create_user_account_no_ckeck( async def create_user_account_no_ckeck(
account: Account | None = None, account: Optional[Account] = None,
wallet_name: str | None = None, wallet_name: Optional[str] = None,
default_exts: list[str] | None = None, default_exts: Optional[list[str]] = None,
conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
if account:
account.validate_fields()
if account.username and await get_account_by_username(
account.username, conn=conn
):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email, conn=conn): if account:
raise ValueError("Email already exists.") account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.pubkey and await get_account_by_pubkey( if account.email and await get_account_by_email(account.email):
account.pubkey, conn=conn raise ValueError("Email already exists.")
):
raise ValueError("Pubkey already exists.")
if not account.id: if account.pubkey and await get_account_by_pubkey(account.pubkey):
account.id = uuid4().hex raise ValueError("Pubkey already exists.")
account = await create_account(account, conn=conn) if not account.id:
await create_wallet( account.id = uuid4().hex
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
conn=conn,
)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions account = await create_account(account)
for ext_id in user_extensions: await create_wallet(
try: user_id=account.id,
user_ext = UserExtension(user=account.id, extension=ext_id, active=True) wallet_name=wallet_name or settings.lnbits_default_wallet_name,
await create_user_extension(user_ext, conn=conn) )
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account, conn=conn) user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions
if not user: for ext_id in user_extensions:
raise ValueError("Cannot find user for account.") try:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await create_user_extension(user_ext)
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
return user return user
@@ -172,12 +164,12 @@ async def check_admin_settings():
settings.first_install = True settings.first_install = True
logger.success( logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` " "✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser." "to get the superuser."
) )
async def init_admin_settings(super_user: str | None = None) -> SuperSettings: async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None account = None
if super_user: if super_user:
account = await get_account(super_user) account = await get_account(super_user)
-202
View File
@@ -1,202 +0,0 @@
from lnbits.core.crud.users import (
get_account,
get_account_by_username_or_email,
update_account,
)
from lnbits.core.crud.wallets import (
create_wallet,
force_delete_wallet,
get_standalone_wallet,
get_wallet,
get_wallets,
update_wallet,
)
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account
from lnbits.core.models.wallets import (
Wallet,
WalletSharePermission,
WalletShareStatus,
WalletType,
)
from lnbits.db import Connection
from lnbits.helpers import sha256s
async def invite_to_wallet(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.username:
raise ValueError("Username or email missing.")
invited_user = await get_account_by_username_or_email(data.username)
if not invited_user:
raise ValueError("Invited user not found.")
request_id = sha256s(invited_user.id + source_wallet.id)
share = source_wallet.extra.find_share_by_id(request_id)
if share:
raise ValueError("User already invited to this wallet.")
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
request_id=request_id,
request_type=WalletShareStatus.INVITE_SENT,
username=data.username,
permissions=data.permissions,
)
await update_wallet(source_wallet)
wallet_owner = await get_account(source_wallet.user)
if not wallet_owner:
raise ValueError("Cannot find wallet owner.")
invited_user.extra.add_wallet_invite_request(
request_id=request_id,
from_user_name=wallet_owner.username or wallet_owner.email,
to_wallet_id=source_wallet.id,
to_wallet_name=source_wallet.name,
)
await update_account(invited_user)
return invite_request
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
invited_user = await get_account(invited_user_id)
if not invited_user:
raise ValueError("Invited user not found.")
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
if not existing_request:
raise ValueError("Invitation not found.")
invited_user.extra.remove_wallet_invite_request(share_request_id)
await update_account(invited_user)
async def update_wallet_share_permissions(
source_wallet: Wallet, data: WalletSharePermission
) -> WalletSharePermission:
if not source_wallet.is_lightning_wallet:
raise ValueError("Only lightning wallets can be shared.")
if not data.shared_with_wallet_id:
raise ValueError("Wallet ID missing.")
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
if not share:
raise ValueError("Share not found")
if not share.shared_with_wallet_id:
raise ValueError("Share does not have a mirror wallet ID.")
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
if not mirror_wallet:
raise ValueError("Target wallet not found")
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
share.approve(permissions=data.permissions)
await update_wallet(source_wallet)
return share
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
if not source_wallet.is_lightning_wallet:
raise ValueError("Source wallet is not a lightning wallet.")
share = source_wallet.extra.find_share_by_id(request_id)
if not share:
raise ValueError("Wallet share not found.")
source_wallet.extra.remove_share_by_id(request_id)
invited_user = await get_account_by_username_or_email(share.username)
if not invited_user:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Invited user not found."
)
if invited_user.extra.find_wallet_invite_request(request_id):
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
mirror_wallets = await get_wallets(
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
)
mirror_wallet = next(
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
)
if not mirror_wallet:
await update_wallet(source_wallet)
return SimpleStatus(
success=True, message="Permission removed. Target wallet not found."
)
if not mirror_wallet.is_lightning_shared_wallet:
raise ValueError("Target wallet is not a shared lightning wallet.")
if mirror_wallet.shared_wallet_id != source_wallet.id:
raise ValueError("Not the owner of the shared wallet.")
await force_delete_wallet(mirror_wallet.id)
await update_wallet(source_wallet)
return SimpleStatus(success=True, message="Permission removed.")
async def create_lightning_shared_wallet(
user_id: str,
source_wallet_id: str,
conn: Connection | None = None,
) -> Wallet:
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
if not source_wallet:
raise ValueError("Shared wallet does not exist.")
if not source_wallet.is_lightning_wallet:
raise ValueError("Shared wallet is not a lightning wallet.")
if source_wallet.user == user_id:
raise ValueError("Cannot mirror your own wallet.")
invited_user = await get_account(user_id, conn=conn)
if not invited_user:
raise ValueError("Cannot find invited user.")
return await _accept_invitation_to_shared_wallet(
invited_user, source_wallet, conn=conn
)
async def _accept_invitation_to_shared_wallet(
invited_user: Account,
source_wallet: Wallet,
conn: Connection | None = None,
) -> Wallet:
request_id = sha256s(invited_user.id + source_wallet.id)
existing_request = source_wallet.extra.find_share_by_id(request_id)
if not existing_request:
raise ValueError("No invitation found for this invited user.")
if existing_request.status == WalletShareStatus.APPROVED:
raise ValueError("This wallet is already shared with you.")
if existing_request.status != WalletShareStatus.INVITE_SENT:
raise ValueError("Unknown request type.")
invited_user.extra.remove_wallet_invite_request(request_id)
await update_account(invited_user)
# todo: double check if user already has a mirror wallet for this source wallet
mirror_wallet = await create_wallet(
user_id=invited_user.id,
wallet_name=source_wallet.name,
wallet_type=WalletType.LIGHTNING_SHARED,
shared_wallet_id=source_wallet.id,
conn=conn,
)
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
await update_wallet(source_wallet, conn=conn)
mirror_wallet.mirror_shared_wallet(source_wallet)
return mirror_wallet
+12 -50
View File
@@ -1,65 +1,27 @@
from asyncio import Queue from fastapi import WebSocket
from dataclasses import dataclass
from fastapi import WebSocket, WebSocketDisconnect
from loguru import logger from loguru import logger
from lnbits.settings import settings
@dataclass
class WebsocketConnection:
item_id: str
websocket: WebSocket
receive_queue: Queue[str]
class WebsocketConnectionManager: class WebsocketConnectionManager:
def __init__(self) -> None: def __init__(self) -> None:
self.active_connections: list[WebsocketConnection] = [] self.active_connections: list[WebSocket] = []
async def connect(self, item_id: str, websocket: WebSocket) -> WebsocketConnection: async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}") logger.debug(f"Websocket connected to {item_id}")
await websocket.accept() await websocket.accept()
conn = WebsocketConnection( self.active_connections.append(websocket)
item_id=item_id,
websocket=websocket,
receive_queue=Queue(),
)
self.active_connections.append(conn)
return conn
async def listen(self, conn: WebsocketConnection) -> None: def disconnect(self, websocket: WebSocket):
while settings.lnbits_running: self.active_connections.remove(websocket)
try:
data = await conn.websocket.receive_text()
logger.debug(f"WS received data from {conn.item_id}: {data}")
conn.receive_queue.put_nowait(data)
except WebSocketDisconnect:
for _conn in self.active_connections:
if _conn.websocket == conn.websocket:
self.active_connections.remove(_conn)
logger.debug(f"WS disconnected from {conn.item_id}")
break # out of the listen and the fastapi route
def get_connections(self, item_id: str) -> list[WebsocketConnection]: async def send_data(self, message: str, item_id: str):
conns = [] for connection in self.active_connections:
for conn in self.active_connections: if connection.path_params["item_id"] == item_id:
if conn.item_id == item_id: await connection.send_text(message)
conns.append(conn)
return conns
def has_connection(self, item_id: str) -> bool:
return len(self.get_connections(item_id)) > 0
async def send(self, item_id: str, data: str) -> None:
for conn in self.get_connections(item_id):
await conn.websocket.send_text(data)
websocket_manager = WebsocketConnectionManager() websocket_manager = WebsocketConnectionManager()
# deprecated import and use `websocket_manager.send()` instead async def websocket_updater(item_id: str, data: str):
async def websocket_updater(item_id: str, data: str) -> None: return await websocket_manager.send_data(data, item_id)
return await websocket_manager.send(item_id, data)
+29 -17
View File
@@ -1,4 +1,6 @@
import asyncio import asyncio
import traceback
from typing import Callable, Coroutine
from loguru import logger from loguru import logger
@@ -10,7 +12,7 @@ from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models.audit import AuditEntry from lnbits.core.models import AuditEntry
from lnbits.core.models.extensions import InstallableExtension from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import ( from lnbits.core.services.funding_source import (
@@ -19,23 +21,24 @@ from lnbits.core.services.funding_source import (
get_balance_delta, get_balance_delta,
) )
from lnbits.core.services.notifications import ( from lnbits.core.services.notifications import (
enqueue_admin_notification, enqueue_notification,
process_next_notification, process_next_notification,
send_payment_notification, send_payment_notification,
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue() audit_queue: asyncio.Queue = asyncio.Queue()
async def run_by_the_minute_tasks() -> None: async def run_by_the_minute_tasks():
minute_counter = 0 minute_counter = 0
while settings.lnbits_running: while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60 status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_threshold_sats > 0: if settings.notification_balance_delta_changed:
try: try:
# runs by default every minute, the delta should not change that often # runs by default every minute, the delta should not change that often
await check_balance_delta_changed() await check_balance_delta_changed()
@@ -57,9 +60,7 @@ async def run_by_the_minute_tasks() -> None:
if minute_counter % 60 == 0: if minute_counter % 60 == 0:
try: try:
# initialize the list of all extensions # initialize the list of all extensions
await InstallableExtension.get_installable_extensions( await InstallableExtension.get_installable_extensions()
post_refresh_cache=True
)
except Exception as ex: except Exception as ex:
logger.error(ex) logger.error(ex)
@@ -67,7 +68,7 @@ async def run_by_the_minute_tasks() -> None:
await asyncio.sleep(60) await asyncio.sleep(60)
async def _notify_server_status() -> None: async def _notify_server_status():
accounts = await get_accounts(filters=Filters(limit=0)) accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count() wallets_count = await get_wallets_count()
payments = await get_payments_status_count() payments = await get_payments_status_count()
@@ -85,10 +86,10 @@ async def _notify_server_status() -> None:
"lnbits_balance_sats": status.lnbits_balance_sats, "lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats, "node_balance_sats": status.node_balance_sats,
} }
enqueue_admin_notification(NotificationType.server_status, values) enqueue_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None: async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
""" """
This worker dispatches events to all extensions and dispatches webhooks. This worker dispatches events to all extensions and dispatches webhooks.
""" """
@@ -101,13 +102,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
await send_payment_notification(wallet, payment) await send_payment_notification(wallet, payment)
async def wait_for_audit_data() -> None: async def wait_for_audit_data():
""" """
Waits for audit entries to be pushed to the queue. Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB. Then it inserts the entries into the DB.
""" """
while settings.lnbits_running: while settings.lnbits_running:
data = await audit_queue.get() data: AuditEntry = await audit_queue.get()
try: try:
await create_audit_entry(data) await create_audit_entry(data)
except Exception as ex: except Exception as ex:
@@ -115,17 +116,17 @@ async def wait_for_audit_data() -> None:
await asyncio.sleep(3) await asyncio.sleep(3)
async def wait_notification_messages() -> None: async def wait_notification_messages():
while settings.lnbits_running: while settings.lnbits_running:
try: try:
await process_next_notification() await process_next_notification()
except Exception as ex: except Exception as ex:
logger.warning("Payment notification error", ex) logger.log("error", ex)
await asyncio.sleep(3) await asyncio.sleep(3)
async def purge_audit_data() -> None: async def purge_audit_data():
""" """
Remove audit entries which have passed their retention period. Remove audit entries which have passed their retention period.
""" """
@@ -139,7 +140,7 @@ async def purge_audit_data() -> None:
await asyncio.sleep(60 * 60) await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data() -> None: async def collect_exchange_rates_data():
""" """
Collect exchange rates data. Used for monitoring only. Collect exchange rates data. Used for monitoring only.
""" """
@@ -161,3 +162,14 @@ async def collect_exchange_rates_data() -> None:
else: else:
sleep_time = 60 sleep_time = 60
await asyncio.sleep(sleep_time) await asyncio.sleep(sleep_time)
def _create_unique_task(name: str, func: Callable):
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
return await func()
try:
create_unique_task(name, _to_coro(func))
except Exception as e:
logger.error(f"Error in {name} task", e)
logger.error(traceback.format_exc())
@@ -1,6 +1,7 @@
<template id="lnbits-admin-audit"> <q-tab-panel name="audit">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6> <h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg"> <div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
@@ -49,12 +50,8 @@
<span v-text="$t('audit_record_warning')"></span> <span v-text="$t('audit_record_warning')"></span>
<br /> <br />
<ul> <ul>
<li> <li><span v-text="$t('audit_record_req_warning_1')"></span></li>
<span v-text="$t('audit_record_req_warning_1')"></span> <li><span v-text="$t('audit_record_req_warning_2')"></span></li>
</li>
<li>
<span v-text="$t('audit_record_req_warning_2')"></span>
</li>
</ul> </ul>
<br /> <br />
<span v-text="$t('audit_record_use')"></span> <span v-text="$t('audit_record_use')"></span>
@@ -136,15 +133,7 @@
multiple multiple
:hint="$t('audit_http_methods_hint')" :hint="$t('audit_http_methods_hint')"
:label="$t('audit_http_methods_label')" :label="$t('audit_http_methods_label')"
:options="[ :options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS'
]"
></q-select> ></q-select>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
@@ -236,4 +225,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -0,0 +1,197 @@
<q-tab-panel name="exchange_providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row">
<div class="col-md-8 col-sm-12">
<div class="q-pa-sm">
<canvas
style="
width: 100% !important;
height: auto !important;
min-height: 350px;
max-height: 50vh;
"
ref="exchangeRatesChart"
></canvas>
</div>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number"
label="Refresh Interval (seconds)"
hint="How often should the exchange rates be fetched. Set to zero to disable."
>
</q-input>
<q-input
filled
v-model="formData.lnbits_exchange_history_size"
type="number"
label="History Size"
hint="How many data points should be kept in memory."
>
</q-input>
<ul>
<li>
<code>Refresh Interval</code> and <code> History Size </code>are for
historical purposes only.
</li>
<li>These two settings do not affect the live price computation.</li>
<li>
Chart currency:
<strong
><span
v-text="formData.lnbits_default_accounting_currency || 'USD'"
></span
></strong>
</li>
</ul>
</div>
</div>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@click="addExchangeProvider()"
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
>
</q-btn>
</div>
<div class="col-6">
<q-btn
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
flat
:label="$t('reset_defaults')"
color="primary"
class="float-right"
>
</q-btn>
</div>
</div>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
:columns="exchangesTable.columns"
v-model:pagination="exchangesTable.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
@click="removeExchangeProvider(props.row)"
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
>
</q-btn>
</q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.name"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td full-width>
<q-input
dense
filled
v-model="props.row.api_url"
@update:model-value="touchSettings()"
type="text"
>
</q-input
></q-td>
<q-td>
<q-input
dense
filled
v-model="props.row.path"
@update:model-value="touchSettings()"
type="text"
>
</q-input>
</q-td>
<q-td>
<q-select
filled
dense
v-model="props.row.exclude_to"
@update:model-value="touchSettings()"
multiple
:options="{{ currencies | safe }}"
></q-select>
</q-td>
<q-td>
<q-btn
@click="showTickerConversionDialog(props.row)"
round
icon="add"
size="sm"
color="gray"
class="q-ml-xs"
>
</q-btn>
<q-chip
v-for="ticker, index in props.row.ticker_conversion"
:key="ticker"
removable
dense
filled
@remove="removeExchangeTickerConversion(props.row, ticker)"
color="primary"
text-color="white"
:label="ticker"
class="ellipsis"
>
</q-chip>
</q-td>
</q-tr>
</template>
</q-table>
<ul>
<li>
<code>API URL</code> and <code>JSON Path</code> fields can use the
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
currency
</li>
<li>
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
lowercase code
</li>
</ul>
<q-separator class="q-ma-md"></q-separator>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="formData.lnbits_exchange_rate_cache_seconds"
type="number"
label="Exchange rate cache (seconds)"
hint="For how many seconds should the exchange rate be cached."
>
</q-input>
</div>
<div class="col-md-8 col-sm-12"></div>
</div>
</q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-extensions"> <q-tab-panel name="extensions">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div> <div>
<h6 class="q-my-none"> <h6 class="q-my-none">
@@ -84,27 +84,6 @@
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_disable_extensions_builder')"></span>
</q-item-label>
<q-item-label caption>
<span
v-text="$t('misc_disable_extensions_builder_label')"
></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_builder_activate_non_admins"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
@@ -126,18 +105,7 @@
</q-item> </q-item>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('extension_builder_manifest_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_builder_manifest_url"
:label="$t('extension_builder_manifest_url')"
:hint="$t('extension_builder_manifest_url_hint')"
></q-input>
</div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-funding"> <q-tab-panel name="funding">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none">
<span v-text="$t('wallets_management')"></span> <span v-text="$t('wallets_management')"></span>
@@ -12,67 +12,45 @@
</p> </p>
<ul> <ul>
<li <li
v-text=" v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
$t('funding_source', {
wallet_class: settings.lnbits_backend_wallet_class
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
$t('node_balance', {
balance: (auditData.node_balance_sats || 0).toLocaleString()
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
$t('lnbits_balance', {
balance: (auditData.lnbits_balance_sats || 0).toLocaleString()
})
"
></li> ></li>
<li <li
v-text=" v-text="$t('funding_reserve_percent', {
$t('funding_reserve_percent', { percent: auditData.lnbits_balance_sats > 0
percent: ? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
auditData.lnbits_balance_sats > 0 : 100
? ( })"
(auditData.node_balance_sats /
auditData.lnbits_balance_sats) *
100
).toFixed(2)
: 100
})
"
></li> ></li>
</ul> </ul>
<br /> <br />
</div> </div>
<div class="col"> <div class="col">
<div v-if="LNBITS_NODE_UI"> {% if LNBITS_NODE_UI_AVAILABLE %}
<p> <p><span v-text="$t('node_management')"></span></p>
<span v-text="$t('node_management')"></span> <q-toggle
</p> :label="$t('toggle_node_ui')"
<q-toggle v-model="formData.lnbits_node_ui"
:label="$t('toggle_node_ui')" ></q-toggle>
v-model="formData.lnbits_node_ui" <q-toggle
></q-toggle> v-if="formData.lnbits_node_ui"
<q-toggle :label="$t('toggle_public_node_ui')"
v-if="formData.lnbits_node_ui" v-model="formData.lnbits_public_node_ui"
:label="$t('toggle_public_node_ui')" ></q-toggle>
v-model="formData.lnbits_public_node_ui" <br />
></q-toggle> <q-toggle
<br /> v-if="formData.lnbits_node_ui"
<q-toggle :label="$t('toggle_transactions_node_ui')"
v-if="formData.lnbits_node_ui" v-model="formData.lnbits_node_ui_transactions"
:label="$t('toggle_transactions_node_ui')" ></q-toggle>
v-model="formData.lnbits_node_ui_transactions" {% else %}
></q-toggle> <p><span v-text="$t('node_management_not_supported')"></span></p>
</div> {% endif %}
<p v-if="!LNBITS_NODE_UI">
<span v-text="$t('node_management_not_supported')"></span>
</p>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -88,9 +66,7 @@
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('fee_reserve_percent')"></span></p>
<span v-text="$t('fee_reserve_percent')"></span>
</p>
<q-input <q-input
type="number" type="number"
filled filled
@@ -112,15 +88,7 @@
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('payment_wait_time')"></span></p>
<span v-text="$t('payment_wait_time')"></span>
<sup>
<q-icon name="info" size="16px" class="q-ml-xs"></q-icon>
<q-tooltip max-width="150px">
<span v-text="$t('payment_wait_time_tooltip')"></span>
</q-tooltip>
</sup>
</p>
<q-input <q-input
type="number" type="number"
filled filled
@@ -134,32 +102,10 @@
</div> </div>
</div> </div>
<div v-if="isSuperUser"> <div v-if="isSuperUser">
<lnbits-admin-funding-sources <lnbits-funding-sources
:form-data="formData" :form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources" :allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/> />
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div> </div>
<q-separator></q-separator> <q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm"> <h6 class="q-mt-lg q-mb-sm">
@@ -247,4 +193,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -0,0 +1,67 @@
<q-tab-panel name="library">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('image_library')"></span>
</h6>
<q-btn
color="primary"
label="Add Image"
@click="$refs.imageInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="imageInput"
accept="image/png, image/jpeg, image/gif"
style="display: none"
@change="onImageInput"
/>
</q-card-section>
<div class="row q-col-gutter-sm q-pa-sm">
<div
v-for="image in library_images"
:key="image.filename"
class="col-6 col-sm-4 col-md-3 col-lg-2"
style="max-width: 200px"
>
<q-card class="q-mb-sm">
<q-img :src="image.url" style="height: 150px" />
<q-card-section
class="q-pt-md q-pb-md row items-center justify-between"
>
<small
><div
class="text-caption ellipsis"
style="max-width: 100px"
:title="image.filename"
v-text="image.filename"
></div
></small>
<q-btn
dense
flat
size="sm"
icon="content_copy"
@click="copyText(image.url)"
:title="$t('copy')"
><q-tooltip>Copy image link</q-tooltip></q-btn
>
<q-btn
dense
flat
size="sm"
icon="delete"
color="negative"
@click="deleteImage(image.filename)"
:title="$t('delete')"
><q-tooltip>Delete image</q-tooltip></q-btn
>
</q-card-section>
</q-card>
</div>
</div>
<div v-if="library_images.length === 0" class="q-pa-xl">
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
</div>
</q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-notifications"> <q-tab-panel name="notifications">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span> <span v-text="$t('notifications_configure')"></span>
@@ -76,25 +76,19 @@
icon="add" icon="add"
></q-btn> ></q-btn>
</q-input> </q-input>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="identifier"></span
></q-chip>
</div>
</q-item-section> </q-item-section>
</q-item> </q-item>
<div>
<q-chip
v-for="identifier in formData.lnbits_nostr_notifications_identifiers"
:key="identifier"
removable
@remove="removeNostrNotificationIdentifier(identifier)"
color="primary"
text-color="white"
class="ellipsis"
:label="identifier"
><q-tooltip
v-if="identifier"
anchor="top middle"
self="bottom middle"
><span v-text="identifier"></span></q-tooltip
></q-chip>
</div>
</div> </div>
<div class="col-sm-12 col-md-6"> <div class="col-sm-12 col-md-6">
@@ -404,14 +398,13 @@
v-text="$t('notification_balance_delta_changed_desc')" v-text="$t('notification_balance_delta_changed_desc')"
></q-item-label> ></q-item-label>
</q-item-section> </q-item-section>
<q-item-section avatar> <q-item-section avatar>
<q-input <q-toggle
class="flow-right" size="md"
type="number" v-model="formData.notification_balance_delta_changed"
min="0" checked-icon="check"
filled color="green"
v-model="formData.notification_balance_delta_threshold_sats" unchecked-icon="clear"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -479,9 +472,7 @@
type="number" type="number"
min="0" min="0"
filled filled
v-model=" v-model="formData.lnbits_notification_incoming_payment_amount_sats"
formData.lnbits_notification_incoming_payment_amount_sats
"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
@@ -504,13 +495,11 @@
type="number" type="number"
min="0" min="0"
filled filled
v-model=" v-model="formData.lnbits_notification_outgoing_payment_amount_sats"
formData.lnbits_notification_outgoing_payment_amount_sats
"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,10 +1,8 @@
<template id="lnbits-admin-security"> <q-tab-panel name="security">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
<span v-text="$t('server_management')"></span>
</h6>
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-md-6">
<p><span v-text="$t('base_url')"></span></p> <p><span v-text="$t('base_url')"></span></p>
<q-input <q-input
filled filled
@@ -18,7 +16,7 @@
<span v-text="$t('authentication')"></span> <span v-text="$t('authentication')"></span>
</h6> </h6>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col-12 col-md-6"> <div class="col-12 col-sm-6">
<q-input <q-input
filled filled
v-model="formData.auth_token_expire_minutes" v-model="formData.auth_token_expire_minutes"
@@ -28,17 +26,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-sm-6">
<q-input
filled
v-model="formData.auth_authentication_cache_minutes"
type="number"
:label="$t('auth_authentication_cache_label')"
:hint="$t('auth_authentication_cache_hint')"
>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-select <q-select
filled filled
v-model="formData.auth_allowed_methods" v-model="formData.auth_allowed_methods"
@@ -46,10 +34,6 @@
:hint="$t('auth_allowed_methods_hint')" :hint="$t('auth_allowed_methods_hint')"
:label="$t('auth_allowed_methods_label')" :label="$t('auth_allowed_methods_label')"
:options="formData.auth_all_methods" :options="formData.auth_all_methods"
:option-label="
option =>
option.length > 25 ? option.substring(0, 22) + '...' : option
"
></q-select> ></q-select>
</div> </div>
</div> </div>
@@ -95,7 +79,7 @@
<strong class="q-my-none q-mb-sm">Google Auth</strong> <strong class="q-my-none q-mb-sm">Google Auth</strong>
<div class="row"> <div class="row">
<div class="col-12 col-md-6 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-input <q-input
filled filled
v-model="formData.google_client_id" v-model="formData.google_client_id"
@@ -104,7 +88,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-md-6 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.google_client_secret" v-model="formData.google_client_secret"
@@ -122,7 +106,7 @@
<strong class="q-my-none q-mb-sm">GitHub Auth</strong> <strong class="q-my-none q-mb-sm">GitHub Auth</strong>
<div class="row"> <div class="row">
<div class="col-12 col-md-6 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<q-input <q-input
filled filled
v-model="formData.github_client_id" v-model="formData.github_client_id"
@@ -131,7 +115,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-md-6 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.github_client_secret" v-model="formData.github_client_secret"
@@ -148,8 +132,8 @@
> >
<strong class="q-my-none q-mb-sm">Keycloak Auth</strong> <strong class="q-my-none q-mb-sm">Keycloak Auth</strong>
<div class="row q-col-gutter-sm q-col-gutter-y-md"> <div class="row">
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12 q-pr-sm">
<q-input <q-input
filled filled
v-model="formData.keycloak_discovery_url" v-model="formData.keycloak_discovery_url"
@@ -157,7 +141,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12 q-pr-sm">
<q-input <q-input
filled filled
v-model="formData.keycloak_client_id" v-model="formData.keycloak_client_id"
@@ -166,7 +150,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-md-4 col-sm-12">
<q-input <q-input
filled filled
v-model="formData.keycloak_client_secret" v-model="formData.keycloak_client_secret"
@@ -175,22 +159,6 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-md-4">
<q-input
filled
v-model="formData.keycloak_client_custom_org"
:label="$t('auth_keycloak_custom_org_label')"
>
</q-input>
</div>
<div class="col-12 col-md-8">
<q-input
filled
v-model="formData.keycloak_client_custom_icon"
:label="$t('auth_keycloak_custom_icon_label')"
>
</q-input>
</div>
</div> </div>
</q-card-section> </q-card-section>
<q-separator></q-separator> <q-separator></q-separator>
@@ -221,11 +189,7 @@
dense dense
flat flat
color="primary" color="primary"
:label=" :label="(serverlogEnabled) ? $t('disable_server_log') : $t('enable_server_log')"
serverlogEnabled
? $t('disable_server_log')
: $t('enable_server_log')
"
></q-btn> ></q-btn>
</div> </div>
<br /> <br />
@@ -233,7 +197,7 @@
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('ip_blocker')"></p> <p v-text="$t('ip_blocker')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-6">
<q-input <q-input
filled filled
v-model="formBlockedIPs" v-model="formBlockedIPs"
@@ -263,7 +227,7 @@
</div> </div>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<q-input <q-input
filled filled
v-model="formAllowedIPs" v-model="formAllowedIPs"
@@ -299,7 +263,7 @@
<div class="col-12 col-md-12"> <div class="col-12 col-md-12">
<p v-text="$t('rate_limiter')"></p> <p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-6">
<q-input <q-input
filled filled
type="number" type="number"
@@ -307,10 +271,10 @@
:label="$t('number_of_requests')" :label="$t('number_of_requests')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-6">
<q-select <q-select
filled filled
:options="[$t('second'), $t('minute'), $t('hour')]" :options="[$t('second'),$t('minute'),$t('hour')]"
v-model="formData.lnbits_rate_limit_unit" v-model="formData.lnbits_rate_limit_unit"
:label="$t('time_unit')" :label="$t('time_unit')"
></q-select> ></q-select>
@@ -356,4 +320,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,51 +1,37 @@
<template id="lnbits-admin-server"> <q-tab-panel name="server">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<div> <div>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
<span v-text="$t('currency_settings')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('allowed_currencies')"></span></p>
<span v-text="$t('allowed_currencies')"></span>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_allowed_currencies" v-model="formData.lnbits_allowed_currencies"
multiple multiple
:hint="$t('allowed_currencies_hint')" :hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')" :label="$t('allowed_currencies')"
:options="g.currencies" :options="{{ currencies | safe }}"
></q-select> ></q-select>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('default_account_currency')"></span></p>
<span v-text="$t('default_account_currency')"></span>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_default_accounting_currency" v-model="formData.lnbits_default_accounting_currency"
clearable clearable
:hint="$t('default_account_currency_hint')" :hint="$t('default_account_currency_hint')"
:label="$t('currency')" :label="$t('currency')"
:options=" :options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
formData.lnbits_allowed_currencies?.length
? formData.lnbits_allowed_currencies
: g.allowedCurrencies
"
></q-select> ></q-select>
</div> </div>
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('payments')"></span></h6>
<span v-text="$t('payments')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('max_outgoing_payment_amount')"></span></p>
<span v-text="$t('max_outgoing_payment_amount')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -58,9 +44,7 @@
</div> </div>
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p> <p><span v-text="$t('max_incoming_payment_amount')"></span></p>
<span v-text="$t('max_incoming_payment_amount')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -75,9 +59,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('wallet_limiter')"></span></h6>
<span v-text="$t('wallet_limiter')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<q-input <q-input
@@ -113,9 +95,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('service_fee')"></span></h6>
<span v-text="$t('service_fee')"></span>
</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('service_fee')"></span></p> <p><span v-text="$t('service_fee')"></span></p>
@@ -151,9 +131,7 @@
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p><span v-text="$t('disable_fee_internal')"></span></p>
<span v-text="$t('disable_fee_internal')"></span>
</p>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label v-text="$t('disable_fee')"></q-item-label> <q-item-label v-text="$t('disable_fee')"></q-item-label>
@@ -177,4 +155,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,8 +1,6 @@
<template id="lnbits-admin-site-customisation"> <q-tab-panel name="site_customisation">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none"> <h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
<span v-text="$t('ui_management')"></span>
</h6>
<br /> <br />
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -12,9 +10,7 @@
filled filled
type="text" type="text"
v-model="formData.lnbits_site_title" v-model="formData.lnbits_site_title"
:label=" :label="$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')"
$t('ui_site_title') + $t('ui_changing_remove_lnbits_elements')
"
></q-input> ></q-input>
<br /> <br />
</div> </div>
@@ -32,19 +28,13 @@
<q-toggle <q-toggle
:tip="$t('ui_toggle_elements_tip')" :tip="$t('ui_toggle_elements_tip')"
v-model="formData.lnbits_show_home_page_elements" v-model="formData.lnbits_show_home_page_elements"
:label=" :label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
formData.lnbits_show_home_page_elements
? $t('ui_elements_enable')
: $t('ui_elements_disable')
"
></q-toggle> ></q-toggle>
</div> </div>
</div> </div>
<div> <div>
<p> <p><span v-text="$t('ui_site_description')"></span></p>
<span v-text="$t('ui_site_description')"></span>
</p>
<q-input <q-input
v-model="formData.lnbits_site_description" v-model="formData.lnbits_site_description"
filled filled
@@ -55,9 +45,7 @@
<br /> <br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p> <p><span v-text="$t('ui_default_wallet_name')"></span></p>
<span v-text="$t('ui_default_wallet_name')"></span>
</p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -75,16 +63,6 @@
:hint="$t('ui_qr_code_logo_hint')" :hint="$t('ui_qr_code_logo_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_apple_touch_icon')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_apple_touch_icon"
label="https://example.com/image.png"
:hint="$t('ui_apple_touch_icon_hint')"
></q-input>
</div>
</div> </div>
<div class="row q-col-gutter-md q-mt-md"> <div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@@ -167,24 +145,18 @@
type="text" type="text"
:label="$t('ad_slots_label')" :label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')" :hint="$t('ad_slots_hint')"
><q-tooltip> >
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
</q-input> </q-input>
<q-toggle <q-toggle
v-model="formData.lnbits_ad_space_enabled" v-model="formData.lnbits_ad_space_enabled"
:label=" :label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
formData.lnbits_ad_space_enabled
? $t('ads_enabled')
: $t('ads_disabled')
"
/> />
<br /> <br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md q-my-md"> <p><span v-text="$t('ui_default_theme')"></span></p>
<div class="col-12" v-text="$t('ui_default_theme')"></div> <div class="row q-col-gutter-md">
<div class="col-12 col-sm-6 col-lg-3"> <div class="col">
<q-select <q-select
v-model="formData.lnbits_default_border" v-model="formData.lnbits_default_border"
:options="globalBorderOptions" :options="globalBorderOptions"
@@ -193,7 +165,7 @@
> >
</q-select> </q-select>
</div> </div>
<div class="col-12 col-sm-6 col-lg-3"> <div class="col">
<q-select <q-select
v-model="formData.lnbits_default_theme" v-model="formData.lnbits_default_theme"
:options="lnbits_theme_options" :options="lnbits_theme_options"
@@ -202,7 +174,7 @@
> >
</q-select> </q-select>
</div> </div>
<div class="col-12 col-sm-6 col-lg-3"> <div class="col">
<q-select <q-select
v-model="formData.lnbits_default_reaction" v-model="formData.lnbits_default_reaction"
:options="reactionOptions" :options="reactionOptions"
@@ -211,7 +183,7 @@
> >
</q-select> </q-select>
</div> </div>
<div class="col-12 col-sm-6 col-lg-3"> <div class="col">
<q-input <q-input
type="text" type="text"
v-model="formData.lnbits_default_bgimage" v-model="formData.lnbits_default_bgimage"
@@ -221,7 +193,7 @@
> >
</q-input> </q-input>
</div> </div>
<div class="col-12 col-sm-6 col-lg-3"> <div class="col">
<q-toggle <q-toggle
type="bool" type="bool"
v-model="formData.lnbits_default_gradient" v-model="formData.lnbits_default_gradient"
@@ -233,4 +205,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
@@ -1,4 +1,4 @@
<template id="lnbits-admin-users"> <q-tab-panel name="users">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('user_management')"></span> <span v-text="$t('user_management')"></span>
@@ -80,4 +80,4 @@
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
</template> </q-tab-panel>
+240
View File
@@ -0,0 +1,240 @@
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges">
<span v-text="$t('save_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="checkChanges"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('restart')"
color="primary"
@click="restartServer"
class="q-ml-md"
>
<q-tooltip v-if="needsRestart">
<span v-text="$t('restart_tooltip')"></span>
</q-tooltip>
<q-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></q-btn>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>
<span v-text="$t('reset_defaults_tooltip')"></span>
</q-tooltip>
</q-btn>
</div>
<div></div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col q-gutter-y-md">
<q-card>
<q-splitter>
<template v-slot:before>
<q-tabs
@update:model-value="showExchangeProvidersTab"
v-model="tab"
vertical
active-color="primary"
>
<q-tab
name="funding"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('funding')"></span></q-tooltip
></q-tab>
<q-tab
name="security"
icon="security"
:label="$q.screen.gt.sm ? $t('security') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('security')"></span></q-tooltip
></q-tab>
<q-tab
name="server"
icon="price_change"
:label="$q.screen.gt.sm ? $t('payments') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('payments')"></span></q-tooltip
></q-tab>
<q-tab
name="exchange_providers"
icon="show_chart"
:label="$q.screen.gt.sm ? $t('exchanges') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('exchanges')"></span></q-tooltip
></q-tab>
<q-tab
name="users"
icon="group"
:label="$q.screen.gt.sm ? $t('users') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('users')"></span></q-tooltip
></q-tab>
<q-tab
name="extensions"
icon="extension"
:label="$q.screen.gt.sm ? $t('extensions') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('extensions')"></span></q-tooltip
></q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span></q-tooltip
></q-tab>
<q-tab
name="audit"
icon="playlist_add_check_circle"
:label="$q.screen.gt.sm ? $t('audit') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('audit')"></span></q-tooltip
></q-tab>
<q-tab
name="library"
icon="image"
:label="$q.screen.gt.sm ? $t('library') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('library')"></span></q-tooltip
></q-tab>
<q-tab
style="word-break: break-all"
name="site_customisation"
icon="language"
:label="$q.screen.gt.sm ? $t('site_customisation') : null"
@update="val => tab = val.name"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-form name="settings_form" id="settings_form">
<q-tab-panels
v-model="tab"
animated
swipeable
vertical
transition-prev="jump-up"
transition-next="jump-up"
>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
{% include "admin/_tab_exchange_providers.html" %} {% include
"admin/_tab_extensions.html" %} {% include
"admin/_tab_notifications.html" %} {% include
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
%}{% include "admin/_tab_audit.html"%}{% include
"admin/_tab_library.html"%}
</q-tab-panels>
</q-form>
</template>
</q-splitter>
</q-card>
</div>
</div>
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="q-mb-md">
<strong v-text="$t('create_ticker_converter')"></strong>
</div>
<div class="row">
<div class="col-12 q-mb-md">
<q-select
filled
dense
v-model="exchangeData.convertFromTicker"
label="From Currency"
:options="{{ currencies | safe }}"
></q-select>
</div>
<div class="col-12">
<q-input
v-model="exchangeData.convertToTicker"
dense
filled
label="New Ticker"
hint="This ticker will be used for the exchange API calls."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="addExchangeTickerConversion()"
label="Add Ticker Conversion"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+201
View File
@@ -0,0 +1,201 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-sm q-pl-lg">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<!-- Optional: Add content here if needed -->
</div>
<div>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#audit"
>
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center q-mb-lg">
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('components')"></strong>
<div style="width: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="componentUseChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('long_running_endpoints')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="longDurationChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_request_methods')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="requestMethodChart"></canvas>
</div>
</q-card>
</div>
<div class="col-lg-3 col-md-6 col-sm-12 text-center">
<q-card class="q-pt-sm">
<strong v-text="$t('http_response_codes')"></strong>
<div style="width: 250px; height: 250px" class="q-pa-sm">
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md justify-center">
<div class="col">
<q-card class="q-pa-md">
<q-table
row-key="id"
:rows="auditEntries"
:columns="auditTable.columns"
v-model:pagination="auditTable.pagination"
:filter="auditTable.search"
:loading="auditTable.loading"
@request="fetchAudit"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-input
v-if="['ip_address', 'user_id', 'path',].includes(col.name)"
v-model="searchData[col.name]"
@keydown.enter="searchAuditBy()"
@update:model-value="searchAuditBy()"
dense
type="text"
filled
clearable
:label="col.label"
>
<template v-slot:append>
<q-icon
name="search"
@click="searchAuditBy()"
class="cursor-pointer"
/>
</template>
</q-input>
<q-select
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
v-model="searchData[col.name]"
:options="searchOptions[col.name]"
@update:model-value="searchAuditBy()"
:label="col.label"
clearable
style="width: 100px"
></q-select>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr auto-width :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.name == 'created_at'">
<q-btn
icon="description"
:disable="!props.row.request_details"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="showDetailsDialog(props.row)"
>
<q-tooltip
><span v-text="$t('request_details')"></span
></q-tooltip>
</q-btn>
<span v-text="formatDate(props.row.created_at)"></span>
<q-tooltip v-if="props.row.delete_at">
<span
v-text="'Will be deleted at: ' + formatDate(props.row.delete_at)"
></span>
</q-tooltip>
</div>
<div
v-else-if="['user_id', 'request_details'].includes(col.name)"
>
<q-btn
v-if="props.row[col.name]"
icon="content_copy"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="copyText(props.row[col.name])"
>
<q-tooltip>Copy</q-tooltip>
</q-btn>
<span v-text="shortify(props.row[col.name])"> </span>
<q-tooltip>
<span v-text="props.row[col.name]"></span>
</q-tooltip>
</div>
<span
v-else
v-text="props.row[col.name]"
@click="searchAuditBy(col.name, props.row[col.name])"
class="cursor-pointer"
></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-card>
</div>
</div>
<q-dialog v-model="auditDetailsDialog.show" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong v-text="$t('http_request_details')"></strong>
<q-input
filled
dense
v-model.trim="auditDetailsDialog.data"
type="textarea"
rows="25"
></q-input>
<div class="row q-mt-lg">
<q-btn
@click="copyText(auditDetailsDialog.data)"
icon="copy_content"
color="grey"
flat
v-text="$t('copy')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+285
View File
@@ -0,0 +1,285 @@
<q-expansion-item
group="extras"
icon="vpn_key"
:label="$t('api_keys_api_docs')"
:content-inset-level="0.5"
>
<q-card-section>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Node URL: </strong><em v-text="origin"></em>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.adminkey)"
></q-icon>
</div>
</q-item-section>
</q-item>
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<div>
<q-icon
:name="inkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="inkeyHidden = !inkeyHidden"
></q-icon>
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText(wallet.inkey)"
></q-icon>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-expansion-item
group="api"
dense
expand-separator
label="Get wallet details"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;string&gt;, "name": &lt;string&gt;, "balance":
&lt;int&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl <span v-text="baseUrl"></span>api/v1/wallet -H "X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i
>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;,
"expiry": &lt;int&gt;, "unit": &lt;string&gt;, "webhook":
&lt;url:string&gt;, "internal": &lt;bool&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span> /api/v1/payments (reveal
admin keys
<q-icon
:name="adminkeyHidden ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="adminkeyHidden = !adminkeyHidden"
></q-icon
>)</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments -d
'{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i v-text="adminkeyHidden ? '****************' : wallet.adminkey"></i
>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Decode an invoice"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"data": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST <span v-text="baseUrl"></span>api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code
>{"X-Api-Key": "<i
v-text="inkeyHidden ? '****************' : wallet.inkey"
></i
>"}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET
<span v-text="baseUrl"></span>api/v1/payments/&lt;payment_hash&gt; -H
"X-Api-Key:
<i v-text="inkeyHidden ? '****************' : wallet.inkey"></i>" -H
"Content-type: application/json"</code
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<code
><span class="text-pink">WS</span>
/api/v1/ws/&lt;invoice_key&gt;</code
>
<h5
class="text-caption q-mt-sm q-mb-none"
v-text="$t('websocket_example')"
></h5>
<code
>wscat -c <span v-text="websocketUrl"></span>/<span
v-text="inkeyHidden ? '****************' : wallet.inkey"
></span
></code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)/payments
</h5>
<code>{"balance": &lt;int&gt;, "payment": &lt;object&gt;}</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-card-section>
<p v-text="$t('reset_wallet_keys_desc')"></p>
<q-btn
unelevated
color="red-10"
@click="resetKeys()"
:label="$t('reset_wallet_keys')"
></q-btn>
</q-card-section>
</q-expansion-item>
+896
View File
@@ -0,0 +1,896 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div>
<div class="q-gutter-y-md">
<q-tabs v-model="tab" align="left">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="api_acls"
:label="$t('access_control_list')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="credentialsData.show">
<q-card-section>
<div class="row">
<div class="col">
<h4 class="q-my-none">
<span v-text="$t('password')"></span>
</h4>
</div>
<div class="col">
<q-img
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="credentialsData.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
></q-input>
<q-input
v-if="user.has_password"
v-model="credentialsData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="credentialsData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
filled
dense
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="credentialsData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
filled
dense
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-btn
@click="updatePassword"
:disable="disableUpdatePassword()"
unelevated
color="primary"
class="float-right"
:label="$t('update_password')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
Nostr <span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
v-model="credentialsData.pubkey"
type="text"
label="Pubkey"
filled
dense
></q-input>
<q-btn
@click="updatePubkey"
unelevated
color="primary"
class="q-mt-md float-right"
:label="$t('update_pubkey')"
>
</q-btn>
</q-card-section>
<q-separator class="q-mt-xl"></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="credentialsData.show = false"
:label="$t('back')"
outline
unelevated
color="grey"
></q-btn>
</q-card-section>
</div>
<div v-else>
<q-card-section v-if="user.extra.picture">
<div class="row">
<div class="col">
<q-img
style="max-width: 100px"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="user.id"
:label="$t('user_id')"
filled
dense
readonly
:type="showUserId ? 'text': 'password'"
class="q-mb-md"
><q-btn
@click="showUserId = !showUserId"
dense
flat
:icon="showUserId ? 'visibility_off' : 'visibility'"
color="grey"
></q-btn>
</q-input>
<q-input
v-model="user.username"
:label="$t('username')"
filled
dense
:readonly="hasUsername"
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.pubkey"
:label="$t('pubkey')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.email"
:label="$t('email')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<div v-if="!user.email" class="row"></div>
<div v-if="!user.email" class="row">
{% if "google-auth" in LNBITS_AUTH_METHODS or
"github-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm text-h6">
<span v-text="$t('verify_email')"></span>:
</div>
{%endif%} {% if "google-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm">
<q-btn
:href="`/api/v1/auth/google?user_id=${user.id}`"
type="a"
outline
no-caps
rounded
color="grey"
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/google-logo.png') }}'"
></q-img>
</q-avatar>
<div>Google</div>
</q-btn>
</div>
{%endif%} {% if "github-auth" in LNBITS_AUTH_METHODS %}
<div class="col q-pa-sm">
<q-btn
:href="`/api/v1/auth/github?user_id=${user.id}`"
type="a"
outline
no-caps
color="grey"
rounded
class="full-width"
>
<q-avatar size="32px" class="q-mr-md">
<q-img
:src="'{{ static_url_for('static', 'images/github-logo.png') }}'"
></q-img>
</q-avatar>
<div>GitHub</div>
</q-btn>
</div>
{%endif%}
</div>
</q-card-section>
<q-card-section v-if="user.extra">
<q-input
v-model="user.extra.first_name"
:label="$t('first_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.extra.last_name"
:label="$t('last_name')"
filled
dense
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.extra.provider"
:label="$t('auth_provider')"
filled
dense
readonly
class="q-mb-md"
>
</q-input>
<q-input
v-model="user.extra.picture"
:label="$t('picture')"
filled
class="q-mb-md"
>
</q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('change_password')"
filled
color="primary"
class="float-right"
></q-btn>
</q-card-section>
</div>
</q-tab-panel>
<q-tab-panel name="theme">
<q-btn
v-if="g.user.admin"
class="absolute-top-right"
flat
round
icon="settings"
to="/admin#site_customisation"
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
<div class="row q-mb-md">
<div class="col-4"><span v-text="$t('language')"></span></div>
<div class="col-8">
<q-btn-dropdown
dense
flat
round
size="sm"
icon="language"
class="q-pl-md"
>
<q-list v-for="(lang, index) in g.langs" :key="index">
<q-item
clickable
v-close-popup
:active="activeLanguage(lang.value)"
@click="changeLanguage(lang.value)"
><q-item-section>
<q-item-label
v-text="lang.display ?? lang.value.toUpperCase()"
></q-item-label>
<q-tooltip
><span v-text="lang.label"></span
></q-tooltip>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('color_scheme')"></span>
</div>
<div class="col-8">
<q-btn
v-if="g.allowedThemes.includes('classic')"
dense
flat
@click="themeChoiceFunc('classic')"
icon="circle"
color="deep-purple"
size="md"
><q-tooltip>classic</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('bitcoin')"
dense
flat
@click="themeChoiceFunc('bitcoin')"
icon="circle"
color="deep-orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('mint')"
dense
flat
@click="themeChoiceFunc('mint')"
icon="circle"
color="green"
size="md"
><q-tooltip>mint</q-tooltip> </q-btn
><q-btn
v-if="g.allowedThemes.includes('autumn')"
dense
flat
@click="themeChoiceFunc('autumn')"
icon="circle"
color="brown"
size="md"
><q-tooltip>autumn</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('monochrome')"
dense
flat
@click="themeChoiceFunc('monochrome')"
icon="circle"
color="grey"
size="md"
><q-tooltip>monochrome</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('salvador')"
dense
flat
@click="themeChoiceFunc('salvador')"
icon="circle"
color="blue-10"
size="md"
><q-tooltip>elSalvador</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('freedom')"
dense
flat
@click="themeChoiceFunc('freedom')"
icon="circle"
color="pink-13"
size="md"
><q-tooltip>freedom</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('cyber')"
dense
flat
@click="themeChoiceFunc('cyber')"
icon="circle"
color="light-green-9"
size="md"
><q-tooltip>cyber</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('flamingo')"
dense
flat
@click="themeChoiceFunc('flamingo')"
icon="circle"
color="pink-3"
size="md"
><q-tooltip>flamingo</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('background_image')"></span>
</div>
<div class="col-8">
<q-input
v-model="bgimageSelection"
:label="$t('background_image')"
@update:model-value="bgimageChoiceFunc"
>
<q-tooltip
><span v-text="$t('background_image')"></span
></q-tooltip>
</q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('toggle_darkmode')"></span>
</div>
<div class="col-8">
<q-btn
dense
flat
round
@click="toggleDarkMode"
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
size="sm"
>
<q-tooltip
><span v-text="$t('toggle_darkmode')"></span
></q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('border_choices')"></span>
</div>
<div class="col-8">
<q-select
v-model="borderSelection"
:options="borderOptions"
label="Borders"
@update:model-value="applyBorder"
>
<q-tooltip
><span v-text="$t('border_choices')"></span
></q-tooltip>
</q-select>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notifications')"></span>
</div>
<div class="col-8">
<lnbits-notifications-btn
v-if="g.user"
pubkey="{{ WEBPUSH_PUBKEY }}"
></lnbits-notifications-btn>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('payment_reactions')"></span>
</div>
<div class="col-8">
<q-select
v-model="reactionSelection"
:options="reactionOptions"
label="Reactions"
@update:model-value="reactionChoiceFunc"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
></q-tooltip>
</q-select>
</div>
</div>
</q-tab-panel>
<q-tab-panel name="api_acls">
<div class="row q-mb-md">
<q-badge v-if="user.admin">
<span
v-text="$t('access_control_list_admin_warning')"
></span>
</q-badge>
</div>
<q-card-section>
<div class="row q-mb-md q-gutter-y-md">
<div class="col-sm-12 col-md-6">
<q-select
v-model="selectedApiAcl.id"
emit-value
map-options
@update:model-value="handleApiACLSelected"
:options="apiAcl.data.map(t => ({label: t.name, value: t.id}))"
:label="$t('access_control_list')"
dense
>
</q-select>
</div>
<div class="col-sm-12 col-md-6">
<q-btn
@click="askPasswordAndRunFunction('newApiAclDialog')"
filled
outline
icon="add"
:label="$t('access_control_list')"
color="grey"
class="float-right"
></q-btn>
</div>
</div>
<div v-if="selectedApiAcl.id">
<div class="row q-mb-md">
<div class="col-sm-12 col-md-6">
<q-select
:options="selectedApiAcl.token_id_list.map(t => ({label: t.name, value: t.id}))"
v-model="apiAcl.selectedTokenId"
emit-value
map-options
:label="$t('api_tokens')"
dense
>
</q-select>
</div>
<div class="col-sm-12 col-md-6 q-pl-sm">
<q-btn
v-if="apiAcl.selectedTokenId"
@click="askPasswordAndRunFunction('deleteToken')"
icon="delete"
filled
color="negative"
class="float-left"
></q-btn>
<q-btn
@click="askPasswordAndRunFunction('newTokenAclDialog')"
outline
icon="add"
:label="$t('api_token')"
filled
color="grey"
class="float-right"
></q-btn>
</div>
</div>
<div v-if="apiAcl.apiToken" class="row q-mb-md">
<div class="col-12">
<q-badge>
<span>Use this token in the HTTP</span>
<strong>
&nbsp;<code>Authorization</code>
&nbsp;
</strong>
<span> header.</span>
</q-badge>
</div>
<div class="col-12">
<table
class="full-width lnbits__table-bordered"
style="
border-collapse: collapse;
background-color: grey;
"
>
<thead>
<tr>
<th>
<span class="float-left">Header Name</span>
</th>
<th>
<span class="float-left">Header Value</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>Authorization</strong>
</td>
<td>
<div class="row q-pt-sm">
<div class="col-2 q-mt-sm">
<strong>Bearer &nbsp;</strong>
</div>
<div class="col-10">
<q-input
v-model="apiAcl.apiToken"
:label="$t('api_token_id')"
filled
dense
readonly
:type="selectedApiAcl.showId ? 'text': 'password'"
class="q-mb-md"
>
<q-btn
@click="selectedApiAcl.showId = !selectedApiAcl.showId"
dense
flat
:icon="selectedApiAcl.showId ? 'visibility_off' : 'visibility'"
color="black"
></q-btn>
<q-btn
@click="copyText(apiAcl.apiToken)"
icon="content_copy"
color="black"
flat
dense
></q-btn>
</q-input>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-12">
<q-badge>
<span
>Please store this token. It cannot be later
retrieved, only revoked.</span
>
</q-badge>
</div>
</div>
<q-table
row-key="path"
:rows="selectedApiAcl.endpoints"
:columns="apiAcl.columns"
v-model:pagination="apiAcl.pagination"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<q-toggle
v-if="col.name == 'read'"
v-model="selectedApiAcl.allRead"
@update:model-value="handleAllEndpointsReadAccess()"
:label="$t('read')"
size="sm"
></q-toggle>
<q-toggle
v-else-if="col.name == 'write'"
v-model="selectedApiAcl.allWrite"
@update:model-value="handleAllEndpointsWriteAccess()"
:label="$t('write')"
size="sm"
></q-toggle>
<span v-else v-text="col.label"></span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<span v-text="props.row.name"></span>
</q-td>
<q-td>
<span v-text="props.row.path"></span>
</q-td>
<q-td>
<q-toggle size="sm" v-model="props.row.read" />
</q-td>
<q-td>
<q-toggle size="sm" v-model="props.row.write" />
</q-td>
</q-tr>
</template>
</q-table>
<q-separator></q-separator>
</div>
<div v-if="selectedApiAcl.id" class="row q-mt-md">
<div class="col-sm-12 col-md-6">
<q-btn
@click="askPasswordAndRunFunction('updateApiACLs')"
:label="$t('update')"
filled
color="primary"
></q-btn>
</div>
<div class="col-sm-12 col-md-6">
<q-btn
@click="askPasswordAndRunFunction('deleteApiACL')"
:label="$t('delete')"
icon="delete"
color="negative"
class="float-right"
>
</q-btn>
</div>
</div>
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-else class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
</q-card-section>
</q-card>
</div>
</div>
<q-dialog v-model="apiAcl.showPasswordDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>User Password</strong>
<div class="row q-mt-md q-col-gutter-md">
<div class="col-12">
<q-input
v-model="apiAcl.password"
type="password"
dense
filled
label="Password"
hint="User password is required for this action."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
@click="runPasswordGuardedFunction()"
:label="$t('ok')"
color="primary"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="apiAcl.showNewAclDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>New API Access Control List</strong>
<div class="row q-mt-md q-col-gutter-md">
<div class="col-12">
<q-input v-model="apiAcl.newAclName" dense filled label="ACL Name">
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn @click="addApiACL()" label="Create" color="primary"></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="apiAcl.showNewTokenDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<strong>New API Token</strong>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12">
<q-input v-model="apiAcl.newTokenName" dense filled label="Token Name">
</q-input>
</div>
<div class="col-12">
<q-input
v-model="apiAcl.newTokenExpiry"
dense
filled
label="Expiration"
hit="Expiration time in minutes (default xxx)"
>
<template v-slot:prepend>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-date v-model="apiAcl.newTokenExpiry" mask="YYYY-MM-DD HH:mm">
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-date>
</q-popup-proxy>
</q-icon>
</template>
<template v-slot:append>
<q-icon name="access_time" class="cursor-pointer">
<q-popup-proxy
cover
transition-show="scale"
transition-hide="scale"
>
<q-time
v-model="apiAcl.newTokenExpiry"
mask="YYYY-MM-DD HH:mm"
format24h
>
<div class="row items-center justify-end">
<q-btn v-close-popup label="Close" color="primary" flat />
</div>
</q-time>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn @click="generateApiToken()" label="Create" color="primary"></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
+991
View File
@@ -0,0 +1,991 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user, wallet, extensions, extension_data)
}}{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div>
<div class="q-gutter-y-md">
<q-tabs
:model-value="tab"
@update:model-value="handleTabChanged"
active-color="primary"
align="left"
>
<q-tab name="installed" :label="$t('installed')"></q-tab>
<q-tab name="all" :label="$t('all')"></q-tab>
<q-tab name="featured" :label="$t('featured')"></q-tab>
<i
v-if="!g.user.admin && tab != 'installed'"
v-text="$t('only_admins_can_install')"
></i>
<q-space></q-space>
<q-input
:label="$t('search_extensions')"
:dense="dense"
class="float-right q-pr-xl"
v-model="searchTerm"
>
<template v-slot:before>
<q-icon name="search"> </q-icon>
</template>
<template v-slot:append>
<q-icon
v-if="searchTerm !== ''"
name="close"
@click="searchTerm = ''"
class="cursor-pointer"
>
</q-icon>
</template>
</q-input>
<q-badge
v-if="g.user.admin && updatableExtensions?.length"
@click="showUpdateAllDialog = true"
color="primary"
class="float-right q-pa-sm q-mr-md cursor-pointer"
>
<span
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
></span>
</q-badge>
<q-btn
v-if="g.user.admin"
flat
round
icon="settings"
to="/admin#extensions"
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
></q-btn>
</q-tabs>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md">
<div
v-if="filteredExtensions"
class="col-12 col-sm-6 col-md-6 col-lg-4"
v-for="extension in filteredExtensions"
:key="extension.id + extension.hash"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col-3">
<q-img
@click="showExtensionDetails(extension.id, extension.details_link)"
v-if="extension.icon"
:src="extension.icon"
spinner-color="white"
style="cursor: pointer; max-width: 100%"
>
<q-tooltip>
<span v-text="extension.shortDescription"></span>
</q-tooltip>
</q-img>
<div v-else>
<q-icon
class="gt-sm"
name="extension"
color="primary"
size="70px"
></q-icon>
<q-icon
class="lt-md"
name="extension"
color="primary"
size="35px"
></q-icon>
</div>
</div>
<div class="col-9 q-pl-md">
<q-badge
v-if="extension.isInstalled"
@click="showManageExtension(extension)"
class="float-right"
color="transparent"
style="cursor: pointer"
>
<span v-text="extension.installedRelease.version"></span>
<q-tooltip
><span v-text="extension.installedRelease.version"></span
></q-tooltip>
</q-badge>
<div
v-if="extension.name"
class="text-h5"
style="cursor: pointer"
@click="showExtensionDetails(extension.id, extension.details_link)"
v-text="extension.name"
></div>
<div>
<lnbits-extension-rating :rating="0" />
</div>
<div style="justify-content: space-between; display: flex">
<q-toggle
size="xs"
v-if="extension.isAvailable && extension.isInstalled && g.user.admin"
:label="extension.isActive ? $t('activated'): $t('deactivated') "
color="secondary"
v-model="extension.isActive"
@update:model-value="toggleExtension(extension)"
><q-tooltip>
&nbsp;
<span
v-text="$t('activate_extension_details')"
></span> </q-tooltip
></q-toggle>
<q-badge
v-if="hasNewVersion(extension)"
@click="showManageExtension(extension)"
color="primary"
class="float-right"
style="cursor: pointer; margin: 5px 0"
>
<span v-text="$t('new_version')"></span>
<q-tooltip
><span v-text="extension.latestRelease.version"></span
></q-tooltip>
</q-badge>
</div>
<div>
<p
style="
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
"
v-text="extension.shortDescription"
></p>
<q-tooltip
><span v-text="extension.shortDescription"></span
></q-tooltip>
</div>
</div>
</div>
<div
id="dependencies"
class="row q-pt-sm"
v-if="extension.dependencies?.length"
>
<div class="col">
<small v-text="$t('extension_depends_on')"></small>
<q-badge
v-for="dep in extension.dependencies"
:key="dep"
color="orange"
>
<small v-text="dep"></small>
</q-badge>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-actions style="min-height: 52px">
<div class="col-10">
<div v-if="!extension.inProgress">
<q-btn
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat
color="primary"
type="a"
:href="extension.id + '/'"
:label="$t('open')"
></q-btn>
<q-btn
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat
color="grey-5"
@click="disableExtension(extension)"
:label="$t('disable')"
></q-btn>
<q-badge
v-if="extension.isAdminOnly && !g.user.admin"
v-text="$t('admin_only')"
>
</q-badge>
<q-btn
v-else-if="extension.isInstalled && extension.isActive && !g.user.extensions.includes(extension.id)"
flat
color="primary"
@click="enableExtensionForUser(extension)"
:label="$t(extension.isPaymentRequired ? 'pay_to_enable': 'enable')"
>
<q-tooltip>
<span v-text="$t('enable_extension_details')">
</span> </q-tooltip
></q-btn>
<q-btn
@click="showManageExtension(extension)"
flat
color="primary"
v-if="g.user.admin"
:label="$t('manage')"
><q-tooltip
><span v-text="$t('manage_extension_details')"></span
></q-tooltip>
</q-btn>
</div>
<div v-else>
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
</div>
</div>
<div class="col-2">
<div v-if="extension.details_link" class="float-right">
<q-btn
@click="showExtensionDetails(extension.id, extension.details_link)"
flat
:label="$t('more')"
>
<q-tooltip>
<span v-text="extension.shortDescription"></span>
</q-tooltip>
</q-btn>
</div>
</div>
</q-card-actions>
</q-card>
</div>
</div>
<q-dialog v-model="showUninstallDialog" position="top">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
<p>
<span v-text="$t('extension_uninstall_warning')"></span><br />
<span v-text="$t('confirm_continue')"></span>
</p>
<div class="row q-mt-lg">
<q-checkbox
v-model="uninstallAndDropDb"
value="false"
label="Cleanup database tables"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left"
><span v-text="$t('extension_db_drop_info')"></span>
</q-tooltip>
</q-checkbox>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="uninstallExtension()"
v-text="$t('uninstall_confirm')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showDropDbDialog" position="top">
<q-card v-if="selectedExtension" class="q-pa-lg">
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
<q-input
v-model="dropDbExtensionId"
:label="selectedExtension.id"
></q-input>
<br />
<p v-text="$t('confirm_continue')"></p>
<div class="row q-mt-lg">
<q-btn
:disable="dropDbExtensionId !== selectedExtension.id"
outline
color="red"
@click="dropExtensionDb()"
v-text="$t('confirm')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showManageExtensionDialog" position="top">
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div v-if="selectedRelease.paymentRequest">
<a :href="'lightning:' + selectedRelease.paymentRequest">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div v-else>
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
</div>
</q-card-section>
<div class="row q-mt-lg">
<div class="col">
<q-btn
v-if="selectedRelease.paymentRequest"
outline
color="grey"
@click="copyText(selectedRelease.paymentRequest)"
:label="$t('copy_invoice')"
></q-btn>
</div>
<div class="col">
<q-btn
v-close-popup
flat
color="grey"
class="float-right q-ml-lg"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card>
<q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-tabs v-model="manageExtensionTab" active-color="primary" align="justify">
<q-tab
name="releases"
:label="$t('releases')"
@update="val => manageExtensionTab = val.name"
></q-tab>
<q-tab
v-if="selectedExtension && selectedExtension.isInstalled"
name="sell"
:label="$t('sell')"
@update="val => manageExtensionTab = val.name"
></q-tab>
</q-tabs>
<div
v-show="manageExtensionTab === 'releases'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
v-if="selectedExtensionRepos"
>
<q-card
flat
bordered
class="my-card"
v-for="repoName of Object.keys(selectedExtensionRepos)"
:key="repoName"
>
<q-expansion-item
:key="repoName"
group="repos"
:caption="repoName"
:content-inset-level="0.5"
:default-opened="selectedExtensionRepos[repoName].isInstalled"
>
<template v-slot:header>
<q-item-section avatar>
<q-avatar
:icon="selectedExtensionRepos[repoName].isInstalled ? 'download_done': 'download'"
:text-color="selectedExtensionRepos[repoName].isInstalled ? 'green' : ''"
/>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-10">
<span v-text="$t('repository')"></span>
<br />
<small v-text="repoName"></small>
<q-tooltip
><span v-text="selectedExtensionRepos[repoName].repo"></span
></q-tooltip>
</div>
<div class="col-2"></div>
</div>
</q-item-section>
</template>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
v-for="release of selectedExtensionRepos[repoName].releases"
:key="release.version"
group="releases"
@click="getGitHubReleaseDetails(release)"
:icon="getReleaseIcon(release)"
:label="release.description"
:caption="release.version"
:content-inset-level="0.5"
:header-class="getReleaseIconColor(release)"
>
<div v-if="release.inProgress">
<q-spinner-bars
color="primary"
size="2.55em"
></q-spinner-bars>
</div>
<div v-else-if="release.error">
<q-icon
class="gt-sm"
name="error"
color="pink"
size="70px"
></q-icon>
<span v-text="$t('release_details_error')"></span>
</div>
<q-card class="no-border" v-else>
<q-card-section v-if="release.is_version_compatible">
<span
v-if="release.requiresPayment && !release.paid_sats"
v-text="$t('extension_cost', {cost: release.cost_sats})"
class="q-mb-lg"
></span>
<span
v-if="release.requiresPayment && release.paid_sats"
class="q-mb-lg"
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
></span>
<div
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
>
<q-btn
v-if="!release.isInstalled"
@click="installExtension(release)"
color="primary unelevated mt-lg pt-lg"
:label="$t('install')"
></q-btn>
</div>
<div v-if="release.requiresPayment && !release.paid_sats">
<div v-if="!release.payment_hash">
<q-input
filled
dense
v-model.number="release.paidAmount"
type="number"
:min="release.cost_sats"
suffix="sat"
class="q-mt-sm"
>
</q-input>
<q-select
filled
dense
emit-value
map-options
v-model="release.wallet"
:options="g.user.walletOptions"
:label="$t('wallet_required')"
class="q-mt-sm"
>
</q-select>
<q-btn
unelevated
color="primary"
@click="payAndInstall(release)"
:disabled="!release.wallet"
class="q-mt-sm"
:label="$t('pay_from_wallet')"
></q-btn>
<q-btn
unelevated
color="primary"
@click="showInstallQRCode(release)"
class="q-mt-sm float-right"
:label="$t('show_qr')"
></q-btn>
</div>
<div v-else>
<br />
<span
class="q-mb-lg q-mt-lg"
v-text="'There is a previous pending invoice for this release.'"
></span>
<br />
<q-btn
unelevated
@click="installExtension(release)"
color="primary"
class="q-mt-sm"
:label="$t('retry_install')"
></q-btn>
<q-btn
unelevated
@click="clearHangingInvoice(release)"
color="primary"
class="q-mt-sm float-right"
:label="$t('new_payment')"
></q-btn>
</div>
</div>
<div>
<q-btn
v-if="release.isInstalled"
@click="showUninstall()"
:label="$t('uninstall')"
flat
color="red"
></q-btn>
</div>
<a
v-if="release.html_url"
class="text-secondary float-right"
:href="release.html_url"
target="_blank"
rel="noopener noreferrer"
style="color: inherit"
v-text="$t('release_notes')"
></a>
</q-card-section>
<q-card-section v-else>
<span
v-text="$t('extension_required_lnbits_version')"
></span>
<span class="q-mr-sm">:</span>
<ul>
<li v-if="release.min_lnbits_version">
<span v-text="$t('min_version')"></span>
<span class="q-mr-sm">:</span>
<strong>
<span v-text="release.min_lnbits_version"></span>
</strong>
</li>
<li v-if="release.max_lnbits_version">
<span v-text="$t('max_version')"></span>
<span class="q-mr-sm">:</span>
<strong>
<span v-text="release.max_lnbits_version"></span>
</strong>
</li>
</ul>
</q-card-section>
<q-card v-if="release.warning">
<q-card-section>
<div class="text-h6">
<q-badge
color="yellow"
text-color="black"
v-text="$t('warning')"
>
</q-badge>
</div>
<div class="text-subtitle2">
<span v-text="release.warning"></span>
</div>
</q-card-section>
</q-card>
<q-separator></q-separator> </q-card
></q-expansion-item>
</q-list>
</q-card-section>
</q-expansion-item>
</q-card>
<div class="row q-mt-lg">
<q-btn
v-if="selectedExtension?.isInstalled"
@click="showUninstall()"
flat
color="red"
v-text="$t('uninstall')"
></q-btn>
<q-btn
v-else-if="selectedExtension?.hasDatabaseTables"
@click="showDropDb()"
flat
color="red"
:label="$t('drop_db')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
<div
v-if="selectedExtension"
v-show="manageExtensionTab === 'sell'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-toggle
v-model="selectedExtension.payToEnable.required"
:label="$t('sell_require')"
color="secondary"
style="max-height: 21px"
></q-toggle>
<q-select
v-if="selectedExtension.payToEnable.required"
filled
dense
emit-value
map-options
v-model="selectedExtension.payToEnable.wallet"
:options="g.user.walletOptions"
label="Wallet *"
class="q-mt-md"
></q-select>
<q-input
v-if="selectedExtension.payToEnable.required"
filled
dense
v-model.number="selectedExtension.payToEnable.amount"
:label="$t('amount_sats')"
type="number"
min="1"
class="q-mt-md"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
@click="updatePayToInstallData(selectedExtension)"
flat
color="green"
v-text="$t('update_payment')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showPayToEnableDialog" position="top">
<q-card v-if="selectedExtension" class="q-pa-md">
<q-card-section>
<p>
<span
v-text="$t('sell_info', {name: selectedExtension.name, amount: selectedExtension.payToEnable.amount})"
></span>
</p>
<p>
<span v-text="$t('already_paid_question')"></span>
<q-badge
@click="enableExtension(selectedExtension)"
color="primary"
class="cursor-pointer"
rounded
>
<strong> <span v-text="$t('recheck')"></span> </strong
></q-badge>
</p>
</q-card-section>
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
<div class="row q-mt-lg">
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
<a
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div v-else class="col">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-btn
v-if="selectedExtension.payToEnable.paymentRequest"
outline
color="grey"
@click="copyText(selectedExtension.payToEnable.paymentRequest)"
:label="$t('copy_invoice')"
></q-btn>
</div>
<div class="col">
<q-btn
v-close-popup
flat
color="grey"
class="float-right q-ml-lg"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card-section>
<q-card-section v-else>
<div class="row q-mt-lg">
<div class="col">
<div>
<q-input
filled
dense
type="number"
v-model.number="selectedExtension.payToEnable.paidAmount"
:min="selectedExtension.payToEnable.amount"
suffix="sat"
class="q-mt-sm"
>
</q-input>
<q-select
filled
dense
v-model="selectedExtension.payToEnable.paymentWallet"
emit-value
map-options
:options="g.user.walletOptions"
:label="$t('wallet_required')"
class="q-mt-sm"
>
</q-select>
<q-separator class="q-mb-lg"></q-separator>
<q-btn
unelevated
color="primary"
class="q-mt-sm"
@click="payAndEnable(selectedExtension)"
:disabled="!selectedExtension.payToEnable.paymentWallet"
:label="$t('pay_from_wallet')"
></q-btn>
<q-btn
unelevated
@click="showEnableQRCode(selectedExtension)"
color="primary"
class="q-mt-sm float-right"
:label="$t('show_qr')"
></q-btn>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="showExtensionDetailsDialog" position="top">
<q-card
v-if="selectedExtensionDetails"
class="q-pa-sm"
style="width: 800px; max-width: 95vw"
>
<q-card-section>
<div class="row">
<div class="col-2 gt-md">
<q-img
:src="selectedExtensionDetails.icon"
style="width: 100px"
type="image"
></q-img>
</div>
<div class="col-9 q-pl-md gt-xs">
<h3 class="q-my-sm" v-text="selectedExtensionDetails.name"></h3>
<h6
class="q-my-sm"
v-text="selectedExtensionDetails.short_description"
></h6>
</div>
<div class="col-9 q-pl-md lt-sm">
<h5 class="q-my-sm" v-text="selectedExtensionDetails.name"></h5>
<p
class="q-my-sm"
v-text="selectedExtensionDetails.short_description"
></p>
</div>
<div class="col-1">
<q-btn
v-close-popup
flat
color="grey"
class="float-right q-ml-lg"
v-text="$t('close')"
></q-btn>
</div>
</div>
<div v-if="selectedExtensionDetails.images?.length" class="row q-my-lg">
<div class="col q-pr-md">
<q-carousel
navigation-position="top"
swipeable
animated
v-model="slide"
v-model:fullscreen="fullscreen"
thumbnails
infinite
:autoplay="autoplay"
arrows
transition-prev="slide-right"
transition-next="slide-left"
@mouseenter="autoplay = false"
@mouseleave="autoplay = true"
height="300px"
>
<template v-slot:control>
<q-carousel-control position="bottom-right" :offset="[18, 18]">
<q-btn
push
round
dense
color="white"
text-color="primary"
:icon="fullscreen ? 'fullscreen_exit' : 'fullscreen'"
@click="fullscreen = !fullscreen"
></q-btn>
</q-carousel-control>
</template>
<q-carousel-slide
v-for="(image, i) of selectedExtensionDetails.images"
:img-src="image.uri"
:key="i"
:name="i"
>
<q-video
v-if="image.link"
class="absolute-full"
:src="image.link"
/>
</q-carousel-slide>
</q-carousel>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-md-8 q-pr-sm">
<div v-html="selectedExtensionDetails.description_md"></div>
</div>
<div class="col-sm-12 col-md-4 q-pl-sm">
<lnbits-extension-rating
:rating="0"
size="2.5em"
></lnbits-extension-rating>
<div class="q-mt-md">
<b>
<span v-text="$t('contributors')"></span>
</b>
<small>
<div v-for="contributor of selectedExtensionDetails.contributors">
<a
:href="contributor.uri"
target="_blank"
rel="noopener noreferrer"
style="color: var(--q-primary); text-decoration: none"
>
<span
v-text="(contributor.name || contributor) + ' - ' + (contributor.role || 'dev')"
></span>
</a>
</div>
</small>
</div>
<div class="q-pt-md">
<div>
<q-btn
size="sm"
color="primary"
label="Repo"
type="a"
:href="selectedExtensionDetails.repo"
target="_blank"
rel="noopener noreferrer"
class="q-pr-xs"
><q-tooltip>repository</q-tooltip></q-btn
>
</div>
<div class="q-pt-lg">
<div>
<b>
<span v-text="$t('license')"></span>
</b>
<q-badge
color="primary"
v-text="selectedExtensionDetails.license"
></q-badge>
</div>
<br />
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="showUpdateAllDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="row">
<div class="col-12">
<h6 class="q-my-md" v-text="$t('update')"></h6>
</div>
</div>
<div v-if="updatableExtensions?.length > 1" class="row">
<div class="col-12">
<q-btn
outline
color="grey"
@click="selectAllUpdatableExtensionss()"
v-text="$t('select_all')"
></q-btn>
</div>
</div>
<q-virtual-scroll :items="updatableExtensions" style="max-height: 400px">
<template v-slot="{ item, index }">
<div class="row">
<div class="q-col">
<q-checkbox
v-model="item.selectedForUpdate"
:disable="item.isUpgraded"
value="false"
:label="item.name + ` (v${item.latestRelease?.version})`"
>
<q-spinner-bars
v-if="item.inProgress"
color="primary"
size="1.5em"
class="q-ml-md"
></q-spinner-bars>
</q-checkbox>
</div>
</div>
</template>
</q-virtual-scroll>
<div class="row q-mt-lg">
<q-btn
@click="updateSelectedExtensions()"
outline
color="grey"
v-text="$t('update')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('cancel')"
></q-btn>
</div>
</q-card>
</q-dialog>
{% endblock %}
@@ -0,0 +1,150 @@
{% extends "public.html" %} {% block page_container %}
<q-page-container>
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
{% block page %}
<div class="row q-col-gutter-md justify-center main">
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section class="grid">
<div>
<h6 class="q-my-none text-center">
<strong v-text="$t('welcome_lnbits')"></strong>
<p><span v-text="$t('setup_su_account')"></span></p>
</h6>
<br />
<q-form class="q-gutter-md">
<q-input
filled
v-model="loginData.username"
:label="$t('username')"
></q-input>
<q-input
filled
v-model.trim="loginData.password"
:type="loginData.isPwd ? 'password' : 'text'"
autocomplete="off"
:label="$t('password')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwd = !loginData.isPwd"
/> </template
></q-input>
<q-input
filled
v-model.trim="loginData.passwordRepeat"
:type="loginData.isPwdRepeat ? 'password' : 'text'"
autocomplete="off"
:label="$t('password_repeat')"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || $t('invalid_password_repeat')]"
><template v-slot:append>
<q-icon
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
/> </template
></q-input>
<q-btn
@click="setPassword()"
unelevated
color="primary"
:label="$t('login')"
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
></q-btn>
</q-form>
</div>
<div class="hero-wrapper">
<div class="hero q-mx-auto"></div>
</div>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<style>
main {
display: flex;
flex-direction: column;
justify-content: center;
}
.grid {
display: block;
}
.hero-wrapper {
display: none;
}
.hero {
display: block;
height: 100%;
max-width: 250px;
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
background-position: center;
background-size: contain;
background-repeat: no-repeat;
}
@media (min-width: 992px) {
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.hero-wrapper {
display: block;
position: relative;
height: 100%;
padding: 1rem;
}
}
</style>
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
loginData: {
isPwd: true,
isPwdRepeat: true,
username: '',
password: '',
passwordRepeat: ''
}
}
},
created() {
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
},
computed: {
checkPasswordsMatch() {
return this.loginData.password !== this.loginData.passwordRepeat
}
},
methods: {
async setPassword() {
try {
await LNbits.api.request(
'PUT',
'/api/v1/auth/first_install',
null,
{
username: this.loginData.username,
password: this.loginData.password,
password_repeat: this.loginData.passwordRepeat
}
)
window.location.href = '/admin'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
})
</script>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
+712
View File
@@ -0,0 +1,712 @@
{% extends "public.html" %} {% block scripts %}
<style>
:root {
--size: 100px;
--gap: 25px;
}
.btn-fixed-width {
/* width: 45%; */
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--gap);
margin: auto;
max-width: 100%;
}
.marquee {
display: flex;
overflow: hidden;
user-select: none;
gap: var(--gap);
height: max-content;
mask-image: linear-gradient(
to right,
hsl(0 0% 0% / 0),
hsl(0 0% 0% / 1) 20%,
hsl(0 0% 0% / 1) 80%,
hsl(0 0% 0% / 0)
);
}
.marquee__group {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--gap);
min-width: 100%;
animation: scroll-x 60s linear infinite;
}
.marquee:hover .marquee__group {
animation-play-state: paused;
}
.marquee__group div {
width: var(--size);
}
@keyframes scroll-x {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
</style>
<script>
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
</script>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
<q-page
class="q-px-md q-py-lg content-center"
:class="{'q-px-lg': $q.screen.gt.xs}"
>
{% block page %}
<div
class="row justify-center items-center"
style="min-height: calc(100vh / 1.618)"
>
<div
class="full-width"
:style="`max-width: ${'{{ LNBITS_CUSTOM_IMAGE }}' ? '850' : '600'}px`"
>
<div class="row q-mb-md">
<div class="col-12">
<div>
<h5
class="q-my-none"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TITLE}}
</h5>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
></div> -->
</div>
</div>
</div>
<div class="row">
<q-badge
v-if="isAccessTokenExpired"
class="q-mx-auto q-mb-md"
color="primary"
rounded
>
<div class="text-h6">
<span v-text="$t('session_has_expired')"></span>
</div>
</q-badge>
<q-card bordered class="full-width q-py-md">
<div class="row">
<div
class="col-12"
:class="{'col-sm-7' : '{{ LNBITS_CUSTOM_IMAGE }}', 'col-lg-6' : '{{ LNBITS_CUSTOM_IMAGE }}'}"
>
{% if lnurl and LNBITS_NEW_ACCOUNTS_ALLOWED and ("user-id-only"
in LNBITS_AUTH_METHODS)%}
<div class="full-height content-center">
<q-card-section>
<div class="text-body1">
<span v-text="$t('claim_desc')"></span>
</div>
</q-card-section>
<q-card-section>
<q-btn
unelevated
color="primary"
@click="processing"
type="a"
href="/lnurlwallet?lightning={{ lnurl }}"
v-text="$t('press_to_claim')"
class="full-width"
></q-btn>
</q-card-section>
</div>
{%else%}
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="allowedRegister"
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
:auth-action="authAction"
v-model:user-name="username"
v-model:password_1="password"
v-model:password_2="passwordRepeat"
v-model:reset-key="reset_key"
@login="login"
@register="register"
@reset="reset"
>
<div
class="text-center text-grey-6"
v-if="authAction !== 'reset'"
>
<p
v-if="authAction === 'login' && allowedRegister"
class="q-mb-none"
>
Not registered? Create an
<span
class="text-secondary cursor-pointer"
@click="showRegister('username-password')"
>Account</span
>
</p>
<p
v-else-if="authAction === 'login' && !allowedRegister"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
<span v-text="$t('existing_account_question')"></span>
<span
class="text-secondary cursor-pointer"
@click="showLogin('username-password')"
v-text="$t('login')"
></span>
</p>
</div>
</username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only
:allowed_new_users="allowedRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
:auth-method="authMethod"
@show-login="showLogin"
@show-register="showRegister"
@login-usr="loginUsr"
@create-wallet="createWallet"
>
</user-id-only>
{%endif%} {% endif %}
</div>
<div
class="col-sm-5 col-lg-6 gt-xs"
v-if="'{{ LNBITS_CUSTOM_IMAGE }}'"
>
<div class="full-height flex flex-center q-pa-lg">
<q-img
:src="'{{ LNBITS_CUSTOM_IMAGE }}'"
:ratio="1"
width="250px"
></q-img>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width q-mb-lg q-mt-sm"
>
<div class="flex flex-center q-gutter-md q-py-md">
<q-btn
outline
color="grey"
type="a"
href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener noreferrer"
:label="$t('view_github')"
class=""
></q-btn>
<q-btn
outline
color="grey"
type="a"
href="https://demo.lnbits.com/lnurlp/link/fH59GD"
target="_blank"
rel="noopener noreferrer"
:label="$t('donate')"
class=""
></q-btn>
</div>
</div>
{% if AD_SPACE %}
<div class="q-pt-md full-width">
<div class="row justify-center q-mb-xl">
<div class="full-width text-center">
<span class="text-uppercase text-grey">{{ AD_SPACE_TITLE }}</span>
</div>
<div class="flex flex-center columm">
{% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
<div class="flex flex-center column q-pr-sm">
<a href="{{ AD[0] }}">
<img
v-if="($q.dark.isActive)"
src="{{ AD[1] }}"
style="max-width: 420px"
class="full-width"
/>
<img
v-else
src="{{ AD[2] }}"
style="max-width: 420px"
class="full-width"
/>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats' && '{{ SITE_TITLE }}' == 'LNbits' && '{{ LNBITS_SHOW_HOME_PAGE_ELEMENTS }}' == 'True'"
class="full-width"
>
<div class="wrapper">
<div class="marquee">
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
<div class="marquee__group">
<div>
<a
href="https://github.com/ElementsProject/lightning"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/cln.png') }}' : '{{ static_url_for('static', 'images/clnl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/lightningnetwork/lnd"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnd.png') }}' : '{{ static_url_for('static', 'images/lnd.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://opennode.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/opennode.png') }}' : '{{ static_url_for('static', 'images/opennodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://lnpay.co/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/lnpay.png') }}' : '{{ static_url_for('static', 'images/lnpayl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/rootzoll/raspiblitz"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blitz.png') }}' : '{{ static_url_for('static', 'images/blitzl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://start9.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/start9.png') }}' : '{{ static_url_for('static', 'images/start9l.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getumbrel.com/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/umbrel.png') }}' : '{{ static_url_for('static', 'images/umbrell.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://mynodebtc.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/mynode.png') }}' : '{{ static_url_for('static', 'images/mynodel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://github.com/shesek/spark-wallet"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/spark.png') }}' : '{{ static_url_for('static', 'images/sparkl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://voltage.cloud"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/voltage.png') }}' : '{{ static_url_for('static', 'images/voltagel.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://breez.technology/sdk/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/breez.png') }}' : '{{ static_url_for('static', 'images/breezl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://blockstream.com/lightning/greenlight/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/greenlight.png') }}' : '{{ static_url_for('static', 'images/greenlightl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/alby.png') }}' : '{{ static_url_for('static', 'images/albyl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://zbd.gg"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/zbd.png') }}' : '{{ static_url_for('static', 'images/zbdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://phoenix.acinq.co/server"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/phoenixd.png') }}' : '{{ static_url_for('static', 'images/phoenixdl.png') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://boltz.exchange/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/boltz.svg') }}' : '{{ static_url_for('static', 'images/boltz.svg') }}'"
></q-img>
</a>
</div>
<div>
<a
href="https://www.blink.sv/"
target="_blank"
rel="noopener noreferrer"
>
<q-img
contain
:src="($q.dark.isActive) ? '{{ static_url_for('static', 'images/blink_logo.png') }}' : '{{ static_url_for('static', 'images/blink_logol.png') }}'"
></q-img>
</a>
</div>
<!-- # -->
</div>
</div>
</div>
</div>
</div>
{% endblock %}
</q-page>
</q-page-container>
{% endblock %}
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block page %}{%
endblock %}

Some files were not shown because too many files have changed in this diff Show More