Compare commits

..
Author SHA1 Message Date
Tiago Vasconcelos f53fee6896 add onboard to wallet 2024-02-15 12:18:52 +00:00
Tiago Vasconcelos f14dfd2522 user onboarding 2024-02-15 09:23:09 +00:00
305 changed files with 24765 additions and 77725 deletions
-2
View File
@@ -3,14 +3,12 @@ data
docker
docs
tests
node_modules
lnbits/static/css/*
lnbits/static/bundle.js
lnbits/static/bundle.css
*.md
!README.md
*.log
.env
+17 -61
View File
@@ -1,17 +1,5 @@
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
######################################
####### 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
# 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"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
######################################
########### Admin Settings ###########
######################################
@@ -21,13 +9,13 @@ AUTH_ALLOWED_METHODS="user-id-only, username-password"
# 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
# Disable this to make LNbits use this config file again.
LNBITS_ADMIN_UI=false
# Change theme
LNBITS_SITE_TITLE="LNbits"
LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
@@ -40,15 +28,12 @@ PORT=5000
######################################
# 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_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# 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
LIGHTNING_INVOICE_EXPIRY=3600
@@ -70,7 +55,7 @@ CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXS
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
# LnbitsWallet
LNBITS_ENDPOINT=https://demo.lnbits.com
LNBITS_ENDPOINT=https://legend.lnbits.com
LNBITS_KEY=LNBITS_ADMIN_KEY
# LndWallet
@@ -99,25 +84,6 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
ALBY_API_ENDPOINT=https://api.getalby.com/
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
# BoltzWallet
BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
BOLTZ_CLIENT_WALLET="lnbits"
# ZBDWallet
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
ZBD_API_KEY=ZBD_ACCESS_TOKEN
# BlinkWallet
BLINK_API_ENDPOINT=https://api.blink.sv/graphql
BLINK_WS_ENDPOINT=wss://ws.blink.sv/graphql
BLINK_TOKEN=BLINK_TOKEN
# PhoenixdWallet
PHOENIXD_API_ENDPOINT=http://localhost:9740/
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
# OpenNodeWallet
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_KEY=OPENNODE_ADMIN_KEY
@@ -130,23 +96,21 @@ LNBITS_DENOMINATION=sats
ECLAIR_URL=http://127.0.0.1:8283
ECLAIR_PASS=eclairpw
# NWCWalllet
NWC_PAIRING_URL="nostr+walletconnect://000...000?relay=example.com&secret=123"
# LnTipsWallet
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
# BreezSdkWallet
BREEZ_API_KEY=KEY
BREEZ_GREENLIGHT_SEED=SEED
# A Greenlight invite code or Greenlight partner certificate/key can be used
BREEZ_GREENLIGHT_INVITE_CODE=CODE
BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRING
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
# BREEZ_USE_TRAMPOLINE=true
######################################
####### Auth Configurations ##########
######################################
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
AUTH_SECRET_KEY=""
AUTH_TOKEN_EXPIRE_MINUTES=525600
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
AUTH_ALLOWED_METHODS="user-id-only, username-password"
# Set this flag if HTTP is used for OAuth
# OAUTHLIB_INSECURE_TRANSPORT="1"
# Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
@@ -168,8 +132,6 @@ KEYCLOAK_DISCOVERY_URL=""
######################################
# 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
@@ -186,9 +148,7 @@ LNBITS_ADMIN_USERS=""
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
# Start LNbits core only. The extensions are not loaded.
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
@@ -209,9 +169,6 @@ LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
# LNBITS_AD_SPACE_TITLE="Supported by"
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
# LNBITS_SHOW_HOME_PAGE_ELEMENTS=true # if set to true, the ad space will be displayed on the home page
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
# LNBITS_CUSTOM_BADGE_COLOR="warning"
# Hides wallet api, extensions can choose to honor
LNBITS_HIDE_API=false
@@ -265,7 +222,6 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
######################################
DEBUG=false
DEBUG_DATABASE=false
BUNDLE_ASSETS=true
# logging into LNBITS_DATA_FOLDER/logs/
+1 -1
View File
@@ -1 +1 @@
custom: https://demo.lnbits.com/lnurlp/link/fH59GD
custom: https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
+7 -11
View File
@@ -4,13 +4,13 @@ inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
default: "3.9"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
node-version:
description: "Node Version"
default: "20.x"
default: "18.x"
npm:
description: "use npm"
default: false
@@ -21,7 +21,7 @@ runs:
using: "composite"
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
# cache poetry install via pip
@@ -38,7 +38,7 @@ runs:
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v4
- uses: actions/cache@v3
name: Define a cache for the virtual environment based on the dependencies lock file
with:
path: ./.venv
@@ -46,19 +46,15 @@ runs:
- name: Install the project dependencies
shell: bash
run: |
poetry env use python${{ inputs.python-version }}
poetry install
# needed for conv tests
poetry add psycopg2-binary
run: poetry install
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: ${{ inputs.node-version }}
- uses: actions/cache@v4
- uses: actions/cache@v3
if: ${{ (inputs.npm == 'true') }}
name: Define a cache for the npm based on the dependencies lock file
with:
-81
View File
@@ -1,81 +0,0 @@
#!/bin/sh
LAUNCH_DIR="$PWD"
# Define persistent storage for extracted LNbits
PERSISTENT_DIR="$HOME/.local/share/lnbits"
# Remove existing LNbits directory before extraction
if [ -d "$PERSISTENT_DIR" ]; then
echo "Removing existing LNbits directory..."
rm -rf "$PERSISTENT_DIR"
fi
# Ensure the persistent directory exists
mkdir -p "$PERSISTENT_DIR"
# Extract LNbits from the AppImage if not already extracted
echo "Extracting LNbits to disk for better performance..."
cp -r "$APPDIR/usr/lnbits"/* "$PERSISTENT_DIR/"
chmod +x "$PERSISTENT_DIR/dist/lnbits"
# Check if the directory exists, and create it if it doesn't
if [ ! -d "$LAUNCH_DIR/lnbits/database" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/database"
echo "Created database directory at $LAUNCH_DIR/lnbits/database"
fi
if [ ! -d "$LAUNCH_DIR/lnbits/extensions" ]; then
mkdir -p "$LAUNCH_DIR/lnbits/extensions"
echo "Created extensions directory at $LAUNCH_DIR/lnbits/extensions"
fi
cd "$PERSISTENT_DIR"
# Export the directory as an environment variable for the app
LNBITS_DATA_FOLDER="${LNBITS_DATA_FOLDER:-$LAUNCH_DIR/lnbits/database}"
LNBITS_EXTENSIONS_PATH="${LNBITS_EXTENSIONS_PATH:-$LAUNCH_DIR/lnbits/extensions}"
export LNBITS_DATA_FOLDER
export LNBITS_EXTENSIONS_PATH
export LNBITS_ADMIN_UI=true
# Define the LNbits URL
URL="http://0.0.0.0:5000"
"./dist/lnbits" "$@" &
LNBITS_PID=$!
# Wait for LNbits to be ready before showing the popup
sleep 3
CLOSED=false
# Function to stop LNbits gracefully
kill_lnbits() {
LN_PIDS=$(lsof -t -i:5000 2>/dev/null) # Capture all PIDs
if [ -n "$LN_PIDS" ]; then
echo "Stopping LNbits (PIDs: $LN_PIDS)..."
kill -2 $LN_PIDS # Send SIGINT to all processes on port 5000
CLOSED=true
fi
}
# Show a GUI with a clickable link to open the browser
if command -v zenity >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
zenity --info --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --ok-label="Close Server"
kill_lnbits
sleep 1
done
elif command -v yad >/dev/null 2>&1; then
while [ "$CLOSED" = false ]; do
yad --title="LNbits" --width=400 --text="<b>LNbits is running.</b>\n\n<a href='$URL'>$URL</a>\n\nClick 'Close Server' to stop LNbits." --button="Close Server":0
kill_lnbits
sleep 1
done
else
echo "No GUI tool found. LNbits is running at $URL"
fi
# Ensure the script doesn't hang after closing
if ps -p $LNBITS_PID >/dev/null 2>&1; then
wait $LNBITS_PID 2>/dev/null || true
fi
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=LNbits
Exec=lnbits
Icon=lnbits
Type=Application
Categories=X-Bitcoin;X-LightningNetwork;X-Finance;Network;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+7 -52
View File
@@ -12,56 +12,25 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml
test-api:
tests:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/api"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-wallets:
migrations:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
python-version: ["3.9"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/wallets"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-unit:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
db-url: ["", "postgres://lnbits:lnbits@0.0.0.0:5432/lnbits"]
uses: ./.github/workflows/tests.yml
with:
custom-pytest: "poetry run pytest tests/unit"
python-version: ${{ matrix.python-version }}
db-url: ${{ matrix.db-url }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
migration:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
uses: ./.github/workflows/migration.yml
with:
python-version: ${{ matrix.python-version }}
make: test-migration
db-name: migration
openapi:
needs: [ lint ]
@@ -74,22 +43,8 @@ jobs:
uses: ./.github/workflows/regtest.yml
strategy:
matrix:
python-version: ["3.10"]
python-version: ["3.9"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
with:
custom-pytest: "poetry run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
backend-wallet-class: ${{ matrix.backend-wallet-class }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
jmeter:
needs: [ lint ]
strategy:
matrix:
python-version: ["3.10"]
poetry-version: ["1.5.1"]
uses: ./.github/workflows/jmeter.yml
with:
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
+2 -2
View File
@@ -10,10 +10,10 @@ on:
jobs:
analyze:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 2
- run: git checkout HEAD^2
+11 -6
View File
@@ -11,15 +11,10 @@ on:
tag:
default: latest
type: string
secrets:
DOCKER_USERNAME:
required: true
DOCKER_PASSWORD:
required: true
jobs:
push_to_dockerhub:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -51,3 +46,13 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push latest tag
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/lnbits:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
-69
View File
@@ -1,69 +0,0 @@
name: JMeter Extension Tests
on:
workflow_call:
inputs:
python-version:
description: "Python Version"
required: true
default: "3.10"
type: string
poetry-version:
description: "Poetry Version"
required: true
default: "1.5.1"
type: string
jobs:
jmeter:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: run LNbits
env:
LNBITS_ADMIN_UI: true
LNBITS_EXTENSIONS_DEFAULT_INSTALL: "watchonly, satspay, tipjar, tpos, lnurlp, withdraw"
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
run: |
poetry run lnbits &
sleep 10
- name: setup java version
run: |
update-java-alternatives --list
sudo update-java-alternatives --set /usr/lib/jvm/temurin-8-jdk-amd64
java -version
- name: clone lnbits-extensions, install jmeter and run tests
env:
JAVA_HOME: /usr/lib/jvm/temurin-8-jdk-amd64
EXTENSIONS_MANIFEST_PATH: "/lnbits/lnbits-extensions/refs/heads/main/extensions.json"
run: |
git clone https://github.com/lnbits/lnbits-extensions
cd lnbits-extensions
mkdir logs
mkdir reports
make install-jmeter
make start-mirror-server
make test
- name: print lnbits log
if: ${{ always() }}
run: |
# catch up time for lnbits
sleep 1
cat data/logs/debug.log
- name: upload jmeter test results
uses: actions/upload-artifact@v4
if: ${{ always() }}
with:
name: jmeter-extension-test-results
path: |
lnbits-extensions/reports/
lnbits-extensions/logs/
+4 -4
View File
@@ -8,7 +8,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10"]
with:
make: checkblack
python-version: ${{ matrix.python-version }}
@@ -17,7 +17,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10"]
with:
make: checkruff
python-version: ${{ matrix.python-version }}
@@ -26,7 +26,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10"]
with:
make: mypy
python-version: ${{ matrix.python-version }}
@@ -35,7 +35,7 @@ jobs:
uses: ./.github/workflows/make.yml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10"]
with:
make: pyright
python-version: ${{ matrix.python-version }}
+3 -3
View File
@@ -21,12 +21,12 @@ jobs:
name: ${{ inputs.make }} (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-24.04"]
os-version: ["ubuntu-latest"]
node-version: ["18.x"]
runs-on: ${{ matrix.os-version }}
steps:
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
- uses: actions/checkout@v3
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
node-version: ${{ matrix.node-version }}
-37
View File
@@ -1,37 +0,0 @@
name: migration
on:
workflow_call:
inputs:
python-version:
description: "python version"
type: string
default: "3.10"
jobs:
make:
name: migration (${{ inputs.python-version }})
strategy:
matrix:
os-version: ["ubuntu-24.04"]
runs-on: ${{ matrix.os-version }}
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
POSTGRES_DB: migration
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- run: make test-migration
+5 -4
View File
@@ -25,13 +25,14 @@ on:
jobs:
nix:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v27
- uses: cachix/install-nix-action@v24
with:
nix_path: nixpkgs=channel:nixos-24.05
- uses: cachix/cachix-action@v15
nix_path: nixpkgs=channel:nixos-23.11
- uses: cachix/cachix-action@v13
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/cachix'
with:
name: lnbits
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
-72
View File
@@ -1,72 +0,0 @@
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 }}
+25 -33
View File
@@ -3,34 +3,39 @@ name: regtest
on:
workflow_call:
inputs:
custom-pytest:
description: "Custom pytest arguments"
required: true
make:
default: test
type: string
python-version:
default: "3.10"
default: "3.9"
type: string
os-version:
default: "ubuntu-24.04"
default: "ubuntu-latest"
type: string
backend-wallet-class:
required: true
type: string
secrets:
CODECOV_TOKEN:
required: true
jobs:
regtest:
runs-on: ${{ inputs.os-version }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: docker build
- name: Set up Docker Buildx
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: |
docker build -t lnbits/lnbits .
uses: docker/setup-buildx-action@v2
- name: Build and push
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
uses: docker/build-push-action@v4
with:
context: .
push: false
tags: lnbits/lnbits:latest
cache-from: type=registry,ref=lnbits/lnbits:latest
cache-to: type=inline
- name: Setup Regtest
run: |
@@ -44,8 +49,11 @@ jobs:
with:
python-version: ${{ inputs.python-version }}
- name: Run pytest
uses: pavelzw/pytest-action@v2
- name: Create fake admin
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: docker exec lnbits-lnbits-1 poetry run python tools/create_fake_admin.py
- name: Run Tests
env:
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }}
@@ -63,27 +71,11 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits
PYTHONUNBUFFERED: 1
DEBUG: true
with:
verbose: true
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: ${{ inputs.custom-pytest }}
report-title: "regtest (${{ inputs.python-version }}, ${{ inputs.backend-wallet-class }}"
run: make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
- name: docker lnbits logs
if: ${{ inputs.backend-wallet-class == 'LNbitsWallet' }}
run: |
docker logs lnbits-lnbits-1
file: ./coverage.xml
-32
View File
@@ -1,32 +0,0 @@
name: release-rc
on:
push:
tags:
- "*-rc[0-9]"
permissions:
contents: write
jobs:
docker:
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+2 -28
View File
@@ -10,11 +10,10 @@ permissions:
contents: write
jobs:
release:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Create github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -27,28 +26,3 @@ jobs:
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
tag: latest
secrets:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
+9 -23
View File
@@ -3,15 +3,14 @@ name: tests
on:
workflow_call:
inputs:
custom-pytest:
description: "Custom pytest arguments"
required: true
make:
default: test
type: string
python-version:
default: "3.10"
default: "3.9"
type: string
os-version:
default: "ubuntu-24.04"
default: "ubuntu-latest"
type: string
db-url:
default: ""
@@ -19,9 +18,6 @@ on:
db-name:
default: "lnbits"
type: string
secrets:
CODECOV_TOKEN:
required: true
jobs:
tests:
@@ -43,30 +39,20 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
- name: Run pytest
uses: pavelzw/pytest-action@v2
- name: Run Tests
env:
LNBITS_DATABASE_URL: ${{ inputs.db-url }}
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
PYTHONUNBUFFERED: 1
DEBUG: true
with:
verbose: true
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: ${{ inputs.custom-pytest }}
report-title: "test (${{ inputs.python-version }}, ${{ inputs.db-url }})"
run: make ${{ inputs.make }}
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
verbose: false
file: ./coverage.xml
+2 -12
View File
@@ -11,7 +11,6 @@ __pycache__
*.egg
*.egg-info
.coverage
.coverage.*
.pytest_cache
.webassets-cache
htmlcov
@@ -35,12 +34,9 @@ __bundle__
coverage.xml
node_modules
lnbits/static/bundle.js
lnbits/static/bundle-components.js
lnbits/static/bundle.css
lnbits/static/bundle.min.js.old
lnbits/static/bundle.min.css.old
lnbits/static/bundle-components.min.js.old
lnbits/upgrades
docker
# Nix
@@ -52,11 +48,5 @@ fly.toml
lnbits-backup.zip
# Ignore extensions (post installable extension PR)
/lnbits/extensions
/lnbits/upgrades/
# builded python package
dist
# jetbrains
.idea
extensions
upgrades/
+3 -3
View File
@@ -14,16 +14,16 @@ repos:
- id: mixed-line-ending
- id: check-case-conflict
- repo: https://github.com/psf/black
rev: 24.2.0
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.2
rev: v0.0.283
hooks:
- id: ruff
args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v4.0.0-alpha.8"
rev: '50c5478ed9e10bf360335449280cf2a67f4edb7a'
hooks:
- id: prettier
types_or: [css, javascript, html, json]
-5
View File
@@ -10,9 +10,4 @@
**/lnbits/static/vendor
**/lnbits/static/bundle.*
**/lnbits/static/bundle-components.*
**/lnbits/static/css/*
flake.lock
.venv
+10 -36
View File
@@ -1,50 +1,24 @@
FROM python:3.12-slim-bookworm AS builder
FROM python:3.10-slim-bullseye
RUN apt-get clean
RUN apt-get update
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
RUN apt-get install -y curl pkg-config build-essential
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
WORKDIR /app
# Only copy the files required to install the dependencies
COPY pyproject.toml poetry.lock ./
RUN mkdir data
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
# needed for backups postgresql-client version 14 (pg_dump)
RUN apt-get update && apt-get -y upgrade && \
apt-get -y install gnupg2 curl lsb-release && \
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
curl -s https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
apt-get update && \
apt-get -y install postgresql-client-14 postgresql-client-common && \
apt-get clean all && rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
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"
RUN apt-get install -y apt-utils wget
RUN echo "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" > /etc/apt/sources.list.d/pgdg.list
RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
RUN apt-get update
RUN apt-get install -y postgresql-client-14
WORKDIR /app
COPY . .
COPY --from=builder /app/.venv .venv
RUN mkdir data
RUN poetry install --only main
+27 -38
View File
@@ -6,10 +6,8 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier checkbundle
test: test-unit test-wallets test-api test-regtest
prettier:
poetry run ./node_modules/.bin/prettier --write .
poetry run ./node_modules/.bin/prettier --write lnbits
pyright:
poetry run ./node_modules/.bin/pyright
@@ -27,7 +25,7 @@ checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
poetry run ./node_modules/.bin/prettier --check lnbits
checkblack:
poetry run black --check .
@@ -38,39 +36,23 @@ checkeditorconfig:
dev:
poetry run lnbits --reload
docker:
docker build -t lnbits/lnbits .
test-wallets:
LNBITS_DATA_FOLDER="./tests/data" \
test:
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/wallets
test-unit:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/unit
test-api:
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/api
test-regtest:
FAKE_WALLET_SECRET="ToTheMoon1" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest tests/regtest
poetry run pytest
test-real-wallet:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
test-migration:
LNBITS_ADMIN_UI=True \
make test-api
make test
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATA_FOLDER="./tests/data" \
@@ -78,7 +60,6 @@ test-migration:
HOST=0.0.0.0 \
PORT=5002 \
LNBITS_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
LNBITS_ADMIN_UI=False \
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_DATABASE_URL="postgres://lnbits:lnbits@localhost:5432/migration" \
@@ -88,7 +69,6 @@ migration:
poetry run python tools/conv.py
openapi:
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
@@ -106,23 +86,32 @@ bak:
sass:
npm run sass
bundle:
bundle_no_bump:
npm install
npm run bundle
npm run sass
npm run vendor_copy
npm run vendor_json
poetry run ./node_modules/.bin/prettier -w ./lnbits/static/vendor.json
npm run vendor_bundle_css
npm run vendor_minify_css
npm run vendor_bundle_js
npm run vendor_minify_js
bundle:
make bundle_no_bump
# increment serviceworker version
awk '/CACHE_VERSION =/ {sub(/[0-9]+$$/, $$NF+1)} 1' lnbits/static/js/service-worker.js > lnbits/static/js/service-worker.js.new
mv lnbits/static/js/service-worker.js.new lnbits/static/js/service-worker.js
checkbundle:
cp lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old
cp lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old
cp lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old
make bundle
make bundle_no_bump
diff -q lnbits/static/bundle.min.js lnbits/static/bundle.min.js.old || exit 1
diff -q lnbits/static/bundle.min.css lnbits/static/bundle.min.css.old || exit 1
diff -q lnbits/static/bundle-components.min.js lnbits/static/bundle-components.min.js.old || exit 1
@echo "Bundle is OK"
rm lnbits/static/bundle.min.js.old
rm lnbits/static/bundle.min.css.old
rm lnbits/static/bundle-components.min.js.old
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
+35 -31
View File
@@ -1,16 +1,17 @@
<picture >
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png" style="width:300px">
<img src="https://i.imgur.com/fyKPgVT.png" style="width:300px">
</picture>
# LNbits BETA
![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)
![Lightning network wallet](https://i.imgur.com/DeIiO0y.png)
[![license-badge]](LICENSE)
[![docs-badge]][docs]
# The world's most powerful suite of bitcoin tools.
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
## Run for yourself, for others, or as part of a stack.
# Free and Open-Source Lightning Wallet Accounts System
LNbits is beta, for responsible disclosure of any concerns please contact an admin in the community chat.
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me
Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server!
LNbits is a Python server that sits on top of any funding source. It can be used as:
@@ -20,7 +21,15 @@ LNbits is a Python server that sits on top of any funding source. It can be used
- Fallback wallet for the LNURL scheme
- Instant wallet for LN demonstrations
LNbits can run on top of almost all Lightning funding sources.
LNbits can run on top of any Lightning funding source. It currently supports the following, but more and more are added regularly:
- LND (REST and gRPC)
- Core Lightning aka CLN (gRPC, REST and Spark)
- Eclair
- LNPay
- LNbits
- OpenNode
- Alby
- LightningTipBot
See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source.
@@ -30,45 +39,40 @@ LNbits is inspired by all the great work of [opennode.com](https://www.opennode.
## Running LNbits
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 as an account system
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv + more to come..
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
![Lightning network wallet](https://i.imgur.com/w8jdGpF.png)
## LNbits extension universe
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
Extend YOUR LNbits to meet YOUR needs.
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
![lnurl ATM](https://i.imgur.com/WfCg8wY.png)
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits as an LNURL-withdraw fallback
## LNbits API
LNURL has a fallback scheme, so if scanned by a regular QR code reader it can default to a URL. LNbits exploits this to generate an instant wallet using the [LNURL-withdraw](https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-withdraw.md).
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilizes LNURL, if the user scans the QR with a regular QR code scanner app, they will still be able to access the funds.
## LNbits node manager
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
LNbits comes packaged with a light node management UI, to make running your node that much easier.
## LNbits as an instant wallet
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app".
## LNbits across all your devices
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
## Tip us
If you like this project [send some tip love](https://demo.lnbits.com/lnurlp/link/fH59GD)!
If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
[docs]: https://github.com/lnbits/lnbits/wiki
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
+4 -2
View File
@@ -5,6 +5,8 @@ title: API reference
nav_order: 3
---
# API reference
[Swagger Docs](https://demo.lnbits.com/docs)
API reference
=============
[Swagger Docs](https://legend.lnbits.com/docs)
+11 -24
View File
@@ -5,39 +5,30 @@ nav_order: 4
has_children: true
---
# For developers
For developers
==============
Thanks for contributing :)
# Run
Follow the [Basic installation: Option 1 (recommended): poetry](https://docs.lnbits.org/guide/installation.html#option-1-recommended-poetry)
guide to install poetry and other dependencies.
Then you can start LNbits uvicorn server with:
Run
=====
This starts the lnbits uvicorn server
```bash
poetry run lnbits
```
Or you can use the following to start uvicorn with hot reloading enabled:
This starts the lnbits uvicorn with hot reloading.
```bash
make dev
# or
poetry run lnbits --reload
```
You might need the following extra dependencies on clean installation of Debian:
```
sudo apt install nodejs
sudo apt install npm
npm install
sudo apt-get install autoconf libtool libpg-dev
```
# Precommit hooks
Precommit hooks
=====
This ensures that all commits adhere to the formatting and linting rules.
@@ -45,35 +36,31 @@ This ensures that all commits adhere to the formatting and linting rules.
make install-pre-commit-hook
```
# Tests
Tests
=====
This project has unit tests that help prevent regressions. Before you can run the tests, you must install a few dependencies:
```bash
poetry install
npm i
```
Then to run the tests:
```bash
make test
```
Run formatting:
```bash
make format
```
Run mypy checks:
```bash
poetry run mypy
```
Run everything:
```bash
make all
```
+16 -21
View File
@@ -5,10 +5,11 @@ title: Making extensions
nav_order: 2
---
# Extension set up
Extension set up
=================
Start off by creating a fork of the [example extension](https://github.com/lnbits/example) into own GitHub repository and rename the repository to `mysuperplugin`:
```sh
cd [my-working-folder]
git clone https://github.com/[my-user-name]/mysuperplugin.git --depth=1 # Let's not use dashes or anything; it doesn't like those.
@@ -17,7 +18,6 @@ rm -rf .git/
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
mv templates/example templates/mysuperplugin # Rename templates folder.
```
- if you are on macOS and having difficulty with 'sed', consider `brew install gnu-sed` and use 'gsed', without -0 option after xargs.
1. Edit `manifest.json` and change the organisation name to your GitHub username.
@@ -30,17 +30,19 @@ mv templates/example templates/mysuperplugin # Rename templates folder.
1. ...
1. Profit!!!
## Extension structure explained
- views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
- views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
- migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
Extension structure explained
-----------------------------
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
... This document is a work-in-progress. Send pull requests if you get stuck, so others don't.
## Adding new dependencies
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.
Adding new dependencies
-----------------------
DO NOT ADD NEW DEPENDENCIES. Try to use the dependencies that are availabe 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`:
@@ -49,9 +51,11 @@ $ poetry add <package>
```
**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 `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` compatability can be tested with `nix build .#checks.x86_64-linux.vmTest`.
## SQLite to PostgreSQL migration
SQLite to PostgreSQL migration
-----------------------
LNbits currently supports SQLite and PostgreSQL databases. There is a migration script `tools/conv.py` that helps users migrate from SQLite to PostgreSQL. This script also copies all extension databases to the new backend.
@@ -60,31 +64,22 @@ LNbits currently supports SQLite and PostgreSQL databases. There is a migration
`mock_data.zip` contains a few lines of sample SQLite data and is used in automated GitHub test to see whether your migration in `conv.py` works. Run your extension and save a few lines of data into a SQLite `your_extension.sqlite3` file. Unzip `tests/data/mock_data.zip`, add `your_extension.sqlite3`, updated `database.sqlite3` and zip it again. Add the updated `mock_data.zip` to your PR.
### running migration locally
you will need a running postgres database
#### create lnbits user for migration database
```console
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
```
#### create migration database
```console
sudo su - postgres -c "psql -c 'CREATE DATABASE migration;'"
```
#### run the migration
```console
make test-migration
```
sudo su - postgres -c "psql -c 'CREATE ROLE lnbits LOGIN PASSWORD 'lnbits';'"
#### clean migration database afterwards, fails if you try again
```console
sudo su - postgres -c "psql -c 'DROP DATABASE IF EXISTS migration;'"
```
+27 -30
View File
@@ -1,32 +1,29 @@
<html>
<head>
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<link
rel="stylesheet"
type="text/css"
href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"
/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div>
<!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: 'https://demo.lnbits.com/openapi.json', //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [SwaggerUIBundle.plugins.DownloadUrl]
})
window.ui = ui
}
</script>
</body>
<head>
<!-- Load the latest Swagger UI code and style from npm using unpkg.com -->
<script src="https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css"/>
<title>My New API</title>
</head>
<body>
<div id="swagger-ui"></div> <!-- Div to hold the UI component -->
<script>
window.onload = function () {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "https://legend.lnbits.com/openapi.json", //Location of Open API spec in the repo
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
})
window.ui = ui
}
</script>
</body>
</html>
+5 -2
View File
@@ -5,11 +5,14 @@ title: Websockets
nav_order: 2
---
# Websockets
Websockets
=================
`websockets` are a great way to add a two way instant data channel between server and client.
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://demo.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://demo.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
Example vue-js function for listening to the websocket:
+14 -16
View File
@@ -4,15 +4,17 @@ title: Admin UI
nav_order: 4
---
# Admin UI
Admin UI
========
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
It is disabled by default and the first time you set the environment variable `LNBITS_ADMIN_UI=true`
the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
From there on the settings from the database are used.
# Super User
Super User
==========
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
@@ -27,52 +29,48 @@ We also added a decorator for the API routes to check for super user.
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
# Admin Users
Admin Users
===========
environment variable: `LNBITS_ADMIN_USERS`, comma-separated list of user ids
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`.
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 inaccessable. Also they have access to all the extension defined in `LNBITS_ADMIN_EXTENSIONS`.
# Allowed Users
Allowed Users
=============
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.
Setting this environment variable also disables account creation.
Account creation can be also disabled by setting `LNBITS_ALLOW_NEW_ACCOUNTS=false`
# How to activate
How to activate
=============
```
$ sudo systemctl stop lnbits.service
$ cd ~/lnbits
$ cd ~/lnbits-legend
$ sudo nano .env
```
-> set: `LNBITS_ADMIN_UI=true`
Now start LNbits once in the terminal window
```
$ poetry run lnbits
```
You can now `cat` the Super User ID:
```
$ cat data/.super_user
123de4bfdddddbbeb48c8bc8382fe123
```
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.
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
After that you will find the __`Admin` / `Manage Server`__ between `Wallets` and `Extensions`
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.
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
Do not forget
```
sudo systemctl start lnbits.service
```
A little hint, if you set `RESET TO DEFAULTS`, then a new Super User Account will also be created. The old one is then no longer valid.
+31 -32
View File
@@ -10,16 +10,16 @@ Go to `Manage Server` > `Server` > `Extensions Manifests`
![image](https://user-images.githubusercontent.com/2951406/213494038-e8152d8e-61f2-4cb7-8b5f-361fc3f9a31f.png)
An `Extension Manifest` is a link to a `JSON` file which contains information about various extensions that can be installed (repository of extensions).
Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/main/docs/guide/extension-install.md#manifest-file) section.
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one).
> **Warning**
> Extensions can have bugs or malicious code, be careful what you install!!
## Install New Extension
Only administrator users can install or upgrade extensions.
Go to `Manage Extensions` > `Add Remove Extensions`
@@ -45,12 +45,13 @@ Select the version to be installed (usually the last one) and click `Install`. O
>
> For Explicit Release: the order of the releases is the one in the "extensions" object
The extension has been installed but it cannot be accessed yet. In order to activate the extension toggle it in the `Activated` state.
Go to `Manage Extensions` (as admin user or regular user). Search for the extension and enable it.
## Uninstall Extension
## Uninstall Extension
On the `Install` page click `Manage` for the extension you want to uninstall:
![image](https://user-images.githubusercontent.com/2951406/213653194-32cbb1da-dcc8-43cf-8a82-1ec5d2d3dc16.png)
@@ -64,7 +65,6 @@ Users will no longer be able to access the extension.
> The database for the extension is not removed. If the extension is re-installed later, the data will be accessible.
## Manifest File
The manifest file is just a `JSON` file that lists a collection of extensions that can be installed. This file is of the form:
```json
@@ -77,32 +77,30 @@ The manifest file is just a `JSON` file that lists a collection of extensions th
There are two ways to specify installable extensions:
### Explicit Release
It goes under the `extensions` object and it is of the form:
```json
{
"id": "lnurlp",
"name": "LNURL Pay Links",
"version": 1,
"shortDescription": "Upgrade to version 111111111",
"icon": "receipt",
"details": "All charge names should be <code>111111111</code>. API panel must show: <br>",
"archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip",
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
"infoNotification": "This is a very old version",
"dependencies": ["other-ext-id"]
}
{
"id": "lnurlp",
"name": "LNURL Pay Links",
"version": 1,
"shortDescription": "Upgrade to version 111111111",
"icon": "receipt",
"details": "All charge names should be <code>111111111</code>. API panel must show: <br>",
"archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip",
"hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a",
"htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1",
"infoNotification": "This is a very old version",
"dependencies": ["other-ext-id"]
}
```
<details><summary>Fields Detailed Description</summary>
| Field | Type | | Description |
| -------------------- | ------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
|----------------------|---------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
| name | string | mandatory | User friendly name for the extension. It will be displayed on the installation page. |
| version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. |
| version | string | mandatory | Version of this release. [Semantic versioning](https://semver.org/) is recommended. |
| shortDescription | string | optional | A few words about the extension. It will be displayed on the installation page. |
| icon | string | optional | quasar valid icon name |
| details | string (html) | optional | Details about this particular release |
@@ -111,30 +109,31 @@ It goes under the `extensions` object and it is of the form:
| htmlUrl | string | optional | Link to the extension home page. |
| infoNotification | string | optional | Users that have this release installed will see a info message for their extension. For example if the extension support will be terminated soon. |
| criticalNotification | string | optional | Reserved for urgent notifications. The admin user will receive a message each time it visits the `Install` page. One example is if the extension has a critical bug. |
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed. |
| dependencies | list | optional | A list of extension IDs. It signals that those extensions must be installed BEFORE the this one can be installed.
</details>
This mode has the advantage of strictly specifying what releases of an extension can be installed.
### GitHub Repository
It goes under the `repos` object and it is of the form:
```json
{
"id": "withdraw",
"organisation": "lnbits",
"repository": "withdraw-extension"
"id": "withdraw",
"organisation": "lnbits",
"repository": "withdraw-extension"
}
```
| Field | Type | Description |
| ------------ | ------ | --------------------------------------------------------------------------------------------------- |
| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
| organisation | string | The GitHub organisation (eg: `lnbits`) |
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
| Field | Type | Description |
|--------------|--------|-------------------------------------------------------|
| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. |
| organisation | string | The GitHub organisation (eg: `lnbits`) |
| repository | string | The GitHub repository name (eg: `withdraw-extension`) |
The admin user will see all releases from the Github repository:
![image](https://user-images.githubusercontent.com/2951406/213508934-11de5ae5-2045-471c-854b-94b6acbf4434.png)
+5 -8
View File
@@ -4,10 +4,10 @@ title: FAQ
nav_order: 5
---
# FAQ - Frequently Asked Questions
## Install options
<ul><p>LNbits is not a node management software but a ⚡️LN only accounting system on top of a funding source.</p>
<details><summary>Funding my LNbits wallet from my node it doesn't work.</summary>
@@ -54,7 +54,6 @@ allow-self-payment=1
</ul>
## Troubleshooting
<ul><details><summary>Message "https error" or network error" when scanning a LNbits QR</summary>
<p>Bad news, this is a routing error that might have quite a lot of reasons. Let´s try a few of the most possible problems and their solutions. First choose your setup</p>
<ul>
@@ -84,7 +83,7 @@ allow-self-payment=1
<details><summary>Wallet-URL deleted, are my funds safu ?</summary>
<ul>
<li>
<details><summary>Wallet on demo server demo.lnbits.com</summary>
<details><summary>Wallet on demo server legend.lnbits</summary>
<p>Always save a copy of your wallet-URL, Export2phone-QR or LNDhub for your own wallets in a safe place. LNbits CANNOT help you to recover them when lost.</p>
</details>
</li>
@@ -101,7 +100,7 @@ allow-self-payment=1
<details><summary>Configure a comment that people see when paying to my LNURLp QR</summary>
<p>When you create a LNURL-p, by default the comment box is not filled. That means comments are not allowed to be attached to payments.<p>
<p>In order to allow comments, add the characters length of the box, from 1 to 250. Once you put a number there,
<p>In order to allow comments, add the characters lenght of the box, from 1 to 250. Once you put a number there,
the comment box will be displayed in the payment process. You can also edit a LNURL-p already created and add that number.</p>
![lnbits-lnurl-comment.png](https://i.postimg.cc/HkJQ9xKr/lnbits-lnurl-comment.png)
@@ -151,11 +150,12 @@ allow-self-payment=1
</details>
<details><summary>Can I configure a name to the payments i make?</summary>
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcoin Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
<p>In LNbits this is currently not possible to do - but to receive. This is only possible if the sender's LN wallet supports <a href="https://github.com/lnurl/luds">LUD-18</a> (nameDesc) like e.g. <a href="https://darthcoin.substack.com/p/obw-open-bitcoin-wallet">Open Bitcion Wallet - OBW</a> does. You will then see an alias/pseudonym in the details section of your LNbits transactions (click the arrows). Note that you can give any name there and it might not be related to the real sender´s name(!) if your receive such.</p>
![lnbits-tx-details.png](https://i.postimg.cc/yYnvyK4w/lnbits-tx-details.png)
</p>
</details>
<details><summary>How can I use a LNbits lndhub account in other wallet apps?</summary>
<p>Open your LNbits with the account / wallet you want to use, go to "manage extensions" and activate the <a href="https://github.com/lnbits/lndhub">LNDHUB extension</a>.</p>
<p>Then open the LNDHUB extension, choose the wallet you want to use and scan the QR code you want to use: "admin" or "invoice only", depending on the security level you want for that wallet.</p>
@@ -166,7 +166,6 @@ allow-self-payment=1
</ul>
## Building hardware tools
<ul> <p>LNbits has all sorts of open APIs and tools to program and connect to a lot of different devices for a gazillion of use-cases. Let us know what you did with it ! Come to the <a href="https://t.me/makerbits">Makerbits Telegram Group</a> if you are interested in building or if you need help with a project - we got you!</p>
<details><summary>ATM - deposit and withdraw in your shop or at your meetup</summary>
@@ -217,7 +216,6 @@ allow-self-payment=1
</ul>
## Use cases of LNbits
<ul><details><summary>Merchant</summary>
<p>LNbits is a powerful solution for merchants, due to the easy setup with various extensions, that can be used for many scenarios.</p>
<p><a href="https://darthcoin.substack.com/p/lnbits-for-small-merchants">Here is an overview of the LNbits tools available for a small restaurant as well as a hotel</a></p>
@@ -264,7 +262,6 @@ allow-self-payment=1
</ul>
## Developing for LNbits
<ul>
<li><a href="https://docs.lnbits.org/devs/development.html">Making extensions / How to use Websockets / API reference</a></li>
<li><a href="https://t.me/lnbits">Telegram LNbits Support Group</a></li></ul>
+4 -24
View File
@@ -1,23 +1,19 @@
## Defining a route with path parameters
**old:**
```python
# with <>
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
```
**new:**
```python
# with curly braces: {}
@offlineshop_ext.get("/lnurl/{item_id}")
```
## Check if a user exists and access user object
**old:**
```python
# decorators
@check_user_exists()
@@ -28,18 +24,14 @@ async def do_routing_stuff():
**new:**
If user doesn't exist, `Depends(check_user_exists)` will raise an exception.
If user exists, `user` will be the user object
```python
# depends calls
@core_html_routes.get("/my_route")
async def extensions(user: User = Depends(check_user_exists)):
pass
```
## Returning data from API calls
**old:**
```python
return (
{
@@ -50,11 +42,9 @@ return (
HTTPStatus.OK,
)
```
FastAPI returns `HTTPStatus.OK` by default id no Exception is raised
**new:**
```python
return {
"id": wallet.wallet.id,
@@ -64,7 +54,6 @@ return {
```
To change the default HTTPStatus, add it to the path decorator
```python
@core_app.post("/api/v1/payments", status_code=HTTPStatus.CREATED)
async def payments():
@@ -72,9 +61,7 @@ async def payments():
```
## Raise exceptions
**old:**
```python
return (
{"message": f"Failed to connect to {domain}."},
@@ -87,7 +74,6 @@ abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
**new:**
Raise an exception to return a status code other than the default status code.
```python
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
@@ -96,9 +82,7 @@ raise HTTPException(
```
## Extensions
**old:**
```python
from quart import Blueprint
@@ -108,7 +92,6 @@ amilk_ext: Blueprint = Blueprint(
```
**new:**
```python
from fastapi import APIRouter
from lnbits.jinja2_templating import Jinja2Templates
@@ -131,12 +114,9 @@ offlineshop_rndr = template_renderer([
```
## Possible optimizations
### Use Redis as a cache server
Instead of hitting the database over and over again, we can store a short lived object in [Redis](https://redis.io) for an arbitrary key.
Example:
- Get transactions for a wallet ID
- User data for a user id
- Wallet data for a Admin / Invoice key
* Get transactions for a wallet ID
* User data for a user id
* Wallet data for a Admin / Invoice key
+47 -80
View File
@@ -6,54 +6,38 @@ nav_order: 2
# Basic installation
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.
You can choose between four package managers, `poetry` and `nix`
## Option 1: AppImage (LInux)
By default, LNbits will use SQLite as its database. You can also use PostgreSQL which is recommended for applications with a high load (see guide below).
### AppImage (Linux)
## Option 1 (recommended): poetry
Go to [releases](https://github.com/lnbits/lnbits/releases) and pull latest AppImage, or:
```sh
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
chmod +x LNbits-latest.AppImage
./LNbits-latest.AppImage --host 0.0.0.0 --port 5000
```
LNbits will create a folder for db and extension files in the folder the AppImage runs from.
## Option 2: Poetry (recommended for developers)
It is recommended to use the latest version of Poetry. Make sure you have Python version `3.12` installed.
### Install Python 3.12
```sh
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update -y
sudo apt install -y python3.12 python3.12-dev # ensure correct headers needed for secp256k1
sudo apt install -y pkg-config python3-dev build-essential # ensure correct headers
python3 --version
```
### Install Poetry
```sh
# If path 'export PATH="$HOME/.local/bin:$PATH"' fails, use the path echoed by the install
curl -sSL https://install.python-poetry.org | python3 - && export PATH="$HOME/.local/bin:$PATH"
```
### install LNbits
Mininum poetry version has is ^1.2, but it is recommended to use latest poetry. (including OSX)
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
poetry env use 3.12
git checkout main
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.9 python3.9-distutils
curl -sSL https://install.python-poetry.org | python3 -
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
export PATH="/home/user/.local/bin:$PATH"
# Next command, you can exchange with python3.10 or newer versions.
# Identify your version with python3 --version and specify in the next line
# command is only needed when your default python is not ^3.9 or ^3.10
poetry env use python3.9
poetry install --only main
mkdir data
cp .env.example .env
# Optional: to set funding source amongst other options via the env `nano .env`
# set funding source amongst other options
nano .env
```
#### Running the server
@@ -64,17 +48,9 @@ poetry run lnbits
# 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
```sh
# A very useful terminal client for getting the supersuer ID, updating extensions, etc
poetry run lnbits-cli --help
```
#### Updating the server
```sh
```
cd lnbits
# Stop LNbits with `ctrl + x`
git pull
@@ -83,7 +59,7 @@ poetry install --only main
# Start LNbits with `poetry run lnbits`
```
## Option 3: Nix
## Option 2: Nix
```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
@@ -113,7 +89,7 @@ nix run
Ideally you would set the environment via the `.env` file,
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.
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
@@ -121,19 +97,16 @@ LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
```
## Option 4: Docker
## Option 3: Docker
use latest version from docker hub
```sh
docker pull lnbits/lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
build the image yourself
```sh
git clone https://github.com/lnbits/lnbits.git
cd lnbits
@@ -143,7 +116,7 @@ mkdir data
docker run --detach --publish 5000:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits/lnbits
```
## Option 5: Fly.io
## Option 4: Fly.io
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.
@@ -176,7 +149,7 @@ 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.
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"`.
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://legend.lnbits.com` in `fly.toml` you should have `LNBITS_ENDPOINT="https://legend.lnbits.com"`.
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>`.
@@ -226,7 +199,7 @@ Problems installing? These commands have helped us install LNbits.
sudo apt install pkg-config libffi-dev libpq-dev
# build essentials for debian/ubuntu
sudo apt install python3.10-dev gcc build-essential
sudo apt install python3.9-dev gcc build-essential
# if the secp256k1 build fails:
# if you used poetry
@@ -273,10 +246,11 @@ You might also need to install additional packages or perform additional setup s
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
# Additional guides
## SQLite to PostgreSQL migration
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.
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:
@@ -298,6 +272,7 @@ make migration
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
## LNbits as a systemd 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:
@@ -359,6 +334,15 @@ Assuming your LNbits is running on port `5000` add:
```
yourdomain.com {
handle /api/v1/payments/sse* {
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
transport http {
keepalive off
compression off
}
}
}
reverse_proxy 0.0.0.0:5000 {
header_up X-Forwarded-Host yourdomain.com
}
@@ -377,7 +361,7 @@ Install Apache2 and enable Apache2 mods:
```sh
apt-get install apache2 certbot
a2enmod headers ssl proxy proxy_http
a2enmod headers ssl proxy proxy-http
```
Create a SSL certificate with LetsEncrypt:
@@ -416,7 +400,7 @@ EOF
Restart Apache2:
```sh
service apache2 restart
service restart apache2
```
## Running behind an Nginx reverse proxy over HTTPS
@@ -448,15 +432,6 @@ server {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_request_headers on;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
listen [::]:443 ssl;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/lnbits.org/fullchain.pem;
@@ -470,40 +445,32 @@ EOF
Restart nginx:
```sh
service nginx restart
service restart nginx
```
## Using https without reverse proxy
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.
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
You can find the install instructions for `mkcert` [here](https://github.com/FiloSottile/mkcert).
Install mkcert on Ubuntu:
```sh
sudo apt install libnss3-tools
curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
chmod +x mkcert-v*-linux-amd64
sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
```
#### Create certificate
To create a certificate, first `cd` into your LNbits folder and execute the following command on Linux:
```sh
openssl req -new -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem
```
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
# 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
@@ -515,6 +482,7 @@ You can then pass the certificate files to uvicorn when you start LNbits:
poetry run uvicorn lnbits.__main__:app --host 0.0.0.0 --port 5000 --ssl-keyfile ./key.pem --ssl-certfile ./cert.pem
```
## LNbits running on Umbrel behind Tor
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.
@@ -526,7 +494,7 @@ To install using docker you first need to build the docker image as:
```
git clone https://github.com/lnbits/lnbits.git
cd lnbits
docker build -t lnbits/lnbits .
docker build -t lnbits-legend .
```
You can launch the docker in a different directory, but make sure to copy `.env.example` from lnbits there
@@ -538,7 +506,6 @@ cp <lnbits_repo>/.env.example .env
and change the configuration in `.env` as required.
Then create the data directory
```
mkdir data
```
@@ -546,7 +513,7 @@ 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
docker run --detach --publish 5000:5000 --name lnbits-legend -e "LNBITS_BACKEND_WALLET_CLASS='FakeWallet'" --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbits-legend
```
Finally you can access your lnbits on your machine at port 5000.
+5 -60
View File
@@ -4,12 +4,15 @@ title: Backend wallets
nav_order: 3
---
# Backend wallets
LNbits can run on top of many Lightning Network funding sources with more being added regularly.
Backend wallets
===============
LNbits can run on top of many lightning-network funding sources with more being added regularly.
A backend wallet can be configured using the following LNbits environment variables:
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -76,15 +79,6 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `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 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`: **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 setting is necessary. You can generate an alby access token here: https://getalby.com/developer/access_tokens/new
@@ -93,55 +87,6 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
- `ALBY_API_ENDPOINT`: https://api.getalby.com/
- `ALBY_ACCESS_TOKEN`: AlbyAccessToken
### Boltz
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`: **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 setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api
- `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 the install
~/.phoenix/phoenix.conf, also see the documentation for phoenixd.
- `LNBITS_BACKEND_WALLET_CLASS`: **PhoenixdWallet**
- `PHOENIXD_API_ENDPOINT`: http://localhost:9740/
- `PHOENIXD_API_PASSWORD`: PhoenixdApiPassword
### Breez SDK
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.
- `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
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
### Nostr Wallet Connect (NWC)
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).
You can configure Nostr Wallet Connect in the admin ui or using the following environment variables:
- `LNBITS_BACKEND_WALLET_CLASS`: **NWCWallet**
- `NWC_PAIRING_URL`: **nostr+walletconnect://...your...pairing...secret...**
+11 -7
View File
@@ -4,17 +4,21 @@ title: Users Guide
nav_order: 1
---
# LNbits, free and open-source Lightning Network wallet/accounts system
LNbits, free and open-source lightning-network wallet/accounts system
=====================================================================
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
- 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 LNbits extension framework
- Part of a development stack via LNbits API
- Fallback wallet for the LNURL scheme
- Instant wallet for LN demonstrations
* 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 LNbits extension framework
* Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
## LNbits as an account system
LNbits as an account system
---------------------------
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending,
export to csv + more to come...
Generated
+16 -16
View File
@@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"lastModified": 1698974481,
"narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"rev": "4bb5e752616262457bc7ca5882192a564c0472d2",
"type": "github"
},
"original": {
@@ -41,16 +41,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1735563628,
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
"lastModified": 1702233072,
"narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
"rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-24.05",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
@@ -66,11 +66,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1724134185,
"narHash": "sha256-nDqpGjz7cq3ThdC98BPe1ANCNlsJds/LLZ3/MdIXjA0=",
"lastModified": 1702334837,
"narHash": "sha256-QZG6+zFshyY+L8m2tlOTm75U5m9y7z01g0josVK+8Os=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "5ee730a8752264e463c0eaf06cc060fd07f6dae9",
"rev": "1f4bcbf1be73abc232a972a77102a3e820485a99",
"type": "github"
},
"original": {
@@ -122,11 +122,11 @@
]
},
"locked": {
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"lastModified": 1699786194,
"narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1",
"type": "github"
},
"original": {
+1 -12
View File
@@ -2,7 +2,7 @@
description = "LNbits, free and open-source Lightning wallet and accounts system";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05";
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
@@ -30,20 +30,9 @@
meta.rev = self.dirtyRev or self.rev;
meta.mainProgram = projectName;
overrides = pkgs.poetry2nix.overrides.withDefaults (final: prev: {
coincurve = prev.coincurve.override { preferWheel = true; };
protobuf = prev.protobuf.override { preferWheel = true; };
ruff = prev.ruff.override { preferWheel = true; };
wallycore = prev.wallycore.override { preferWheel = true; };
tlv8 = prev.tlv8.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools
];
});
pynostr = prev.pynostr.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
prev.setuptools-scm
];
});
});
};
});
-24
View File
@@ -1,24 +0,0 @@
from .core.services import create_invoice, pay_invoice
from .decorators import (
check_admin,
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from .exceptions import InvoiceError, PaymentError
__all__ = [
# decorators
"require_admin_key",
"require_invoice_key",
"check_admin",
"check_super_user",
"check_user_exists",
# services
"pay_invoice",
"create_invoice",
# exceptions
"PaymentError",
"InvoiceError",
]
+353 -218
View File
@@ -1,64 +1,55 @@
import asyncio
import glob
import importlib
import logging
import os
import shutil
import signal
import sys
from contextlib import asynccontextmanager
import traceback
from hashlib import sha256
from http import HTTPStatus
from pathlib import Path
from typing import Callable, Optional
from typing import Callable, List, Optional
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import JSONResponse
from lnbits.core.crud import (
get_db_version,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.crud import get_dbversions, get_installed_extensions
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_notification
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
from lnbits.core.services import websocketUpdater
from lnbits.core.tasks import ( # register_watchdog,; unregister_watchdog,
register_killswitch,
register_task_listeners,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
from lnbits.tasks import cancel_all_tasks, create_permanent_task
from lnbits.utils.cache import cache
from lnbits.utils.logger import (
configure_logger,
initialize_server_websocket_logger,
log_server_info,
)
from lnbits.wallets import get_funding_source, set_funding_source
from lnbits.wallets import get_wallet_class, set_wallet_class
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.models.extensions import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import (
Extension,
InstallableExtension,
get_valid_extensions,
version_parse,
)
from .helpers import template_renderer
from .middleware import (
AuditMiddleware,
CustomGZipMiddleware,
ExtensionsRedirectMiddleware,
InstalledExtensionMiddleware,
add_first_install_middleware,
@@ -70,78 +61,10 @@ from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
webhook_handler,
)
async def startup(app: FastAPI):
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
# setup admin settings
await check_admin_settings()
core_app_extra.register_new_ratelimiter()
await check_webpush_settings()
# check extensions after restart
if not settings.lnbits_extensions_deactivate_all:
await check_and_register_extensions(app)
log_server_info()
# initialize WALLET
try:
set_funding_source()
except Exception as e:
logger.error(f"Error initializing {settings.lnbits_backend_wallet_class}: {e}")
set_void_wallet_class()
# initialize funding source
await check_funding_source()
# register core routes
init_core_routers(app)
# initialize tasks
register_async_tasks()
enqueue_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
"up_time": settings.lnbits_server_up_time,
},
)
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
"up_time": settings.lnbits_server_up_time,
},
)
settings.lnbits_running = False
# shutdown event
cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
funding_source = get_funding_source()
await funding_source.cleanup()
@asynccontextmanager
async def lifespan(app: FastAPI):
await startup(app)
yield
await shutdown()
def create_app() -> FastAPI:
configure_logger()
app = FastAPI(
@@ -151,7 +74,6 @@ def create_app() -> FastAPI:
"accounts system with plugins."
),
version=settings.version,
lifespan=lifespan,
license_info={
"name": "MIT License",
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
@@ -159,8 +81,8 @@ def create_app() -> FastAPI:
)
# Allow registering new extensions routes without direct access to the `app` object
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app))
setattr(core_app_extra, "register_new_ratelimiter", register_new_ratelimiter(app))
# register static files
static_path = Path("lnbits", "static")
@@ -172,9 +94,10 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
app.add_middleware(
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
)
# required for SSO login
app.add_middleware(SessionMiddleware, secret_key=settings.auth_secret_key)
@@ -191,50 +114,65 @@ def create_app() -> FastAPI:
add_ip_block_middleware(app)
add_ratelimit_middleware(app)
register_startup(app)
register_async_tasks(app)
register_exception_handlers(app)
register_shutdown(app)
return app
async def check_funding_source() -> None:
funding_source = get_funding_source()
original_sigint_handler = signal.getsignal(signal.SIGINT)
max_retries = settings.funding_source_max_retries
def signal_handler(signal, frame):
logger.debug(
f"SIGINT received, terminating LNbits. signal: {signal}, frame: {frame}"
)
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
WALLET = get_wallet_class()
# fallback to void after 30 seconds of failures
sleep_time = 5
timeout = int(30 / sleep_time)
balance = 0
retry_counter = 0
while settings.lnbits_running:
while True:
try:
logger.info(f"Connecting to backend {funding_source.__class__.__name__}...")
error_message, balance = await funding_source.status()
error_message, balance = await WALLET.status()
if not error_message:
retry_counter = 0
logger.success(
f"✔️ Backend {funding_source.__class__.__name__} connected "
f"and with a balance of {balance} msat."
)
break
logger.error(
f"The backend for {funding_source.__class__.__name__} isn't "
f"The backend for {WALLET.__class__.__name__} isn't "
f"working properly: '{error_message}'",
RuntimeWarning,
)
except Exception as e:
logger.error(
f"Error connecting to {funding_source.__class__.__name__}: {e}"
)
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
pass
if retry_counter >= max_retries:
if settings.lnbits_admin_ui and retry_counter == timeout:
set_void_wallet_class()
funding_source = get_funding_source()
WALLET = get_wallet_class()
break
else:
logger.warning(f"Retrying connection to backend in {sleep_time} seconds...")
retry_counter += 1
await asyncio.sleep(sleep_time)
retry_counter += 1
sleep_time = min(0.25 * (2**retry_counter), 60)
logger.warning(
f"Retrying connection to backend in {sleep_time} seconds... "
f"({retry_counter}/{max_retries})"
)
await asyncio.sleep(sleep_time)
signal.signal(signal.SIGINT, original_sigint_handler)
logger.success(
f"✔️ Backend {WALLET.__class__.__name__} connected "
f"and with a balance of {balance} msat."
)
def set_void_wallet_class():
@@ -242,7 +180,7 @@ def set_void_wallet_class():
"Fallback to VoidWallet, because the backend for "
f"{settings.lnbits_backend_wallet_class} isn't working properly"
)
set_funding_source("VoidWallet")
set_wallet_class("VoidWallet")
async def check_installed_extensions(app: FastAPI):
@@ -252,12 +190,12 @@ async def check_installed_extensions(app: FastAPI):
re-created. The 'data' directory (where the '.zip' files live) is expected to
persist state. Zips that are missing will be re-downloaded.
"""
shutil.rmtree(os.path.join("lnbits", "upgrades"), True)
installed_extensions = await build_all_installed_extensions_list(False)
for ext in installed_extensions:
try:
installed = await check_installed_extension_files(ext)
installed = check_installed_extension_files(ext)
if not installed:
await restore_installed_extension(app, ext)
logger.info(
@@ -266,7 +204,6 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -278,35 +215,16 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> list[InstallableExtension]:
) -> List[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
"""
installed_extensions = await get_installed_extensions()
settings.lnbits_installed_extensions_ids = {e.id for e in installed_extensions}
for ext_dir in Path(settings.lnbits_extensions_path, "extensions").iterdir():
try:
if not ext_dir.is_dir():
continue
ext_id = ext_dir.name
if ext_id in settings.lnbits_installed_extensions_ids:
continue
ext_info = InstallableExtension.from_ext_dir(ext_id)
if not ext_info:
continue
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
installed_extensions_ids = [e.id for e in installed_extensions]
for ext_id in settings.lnbits_extensions_default_install:
if ext_id in settings.lnbits_installed_extensions_ids:
if ext_id in installed_extensions_ids:
continue
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
@@ -317,13 +235,8 @@ async def build_all_installed_extensions_list(
release = next((e for e in ext_releases if e.is_version_compatible), None)
if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
)
installed_extensions.append(ext_info)
@@ -340,38 +253,47 @@ async def build_all_installed_extensions_list(
]
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
def check_installed_extension_files(ext: InstallableExtension) -> bool:
if ext.has_installed_version:
return True
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
if f"./{ext.zip_path!s}" not in zip_files:
await ext.download_archive()
if f"./{str(ext.zip_path)}" not in zip_files:
ext.download_archive()
ext.extract_archive()
return False
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
if extension.upgrade_hash:
ext.nofiy_upgrade()
def register_routes(app: FastAPI) -> None:
"""Register FastAPI routes / LNbits extensions."""
init_core_routers(app)
for ext in get_valid_extensions(False):
try:
register_ext_routes(app, ext)
except Exception as e:
logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
def register_custom_extensions_path():
upgrades_dir = settings.lnbits_extensions_upgrade_path
shutil.rmtree(upgrades_dir, True)
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if settings.has_default_extension_path:
return
default_ext_path = os.path.join("lnbits", "extensions")
@@ -386,9 +308,8 @@ def register_custom_extensions_path():
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
extensions_dir = Path(settings.lnbits_extensions_path, "extensions")
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(extensions_dir))
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
def register_new_ext_routes(app: FastAPI) -> Callable:
@@ -414,21 +335,16 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
return register_new_ratelimiter_fn
def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks."""
ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext")
if hasattr(ext_module, f"{ext.code}_start"):
ext_start_func = getattr(ext_module, f"{ext.code}_start")
ext_start_func()
if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics:
@@ -437,49 +353,268 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
ext_redirects = (
getattr(ext_module, f"{ext.code}_redirect_paths")
if hasattr(ext_module, f"{ext.code}_redirect_paths")
else []
)
if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")
settings.lnbits_extensions_redirects = [
r for r in settings.lnbits_extensions_redirects if r["ext_id"] != ext.code
]
for r in ext_redirects:
r["ext_id"] = ext.code
settings.lnbits_extensions_redirects.append(r)
settings.activate_extension_paths(ext.code, ext.upgrade_hash, ext_redirects)
logger.trace(f"adding route for extension {ext_module}")
logger.trace(f"Adding route for extension {ext_module}.")
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else ""
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI):
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
def register_startup(app: FastAPI):
@app.on_event("startup")
async def lnbits_startup():
try:
register_ext_routes(app, ext)
register_ext_tasks(ext)
except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
# wait till migration is done
await migrate_databases()
# setup admin settings
await check_admin_settings()
await check_webpush_settings()
log_server_info()
# initialize WALLET
try:
set_wallet_class()
except Exception as e:
logger.error(
f"Error initializing {settings.lnbits_backend_wallet_class}: {e}"
)
set_void_wallet_class()
# initialize funding source
await check_funding_source()
# check extensions after restart
await check_installed_extensions(app)
# register core and extension routes
register_routes(app)
if settings.lnbits_admin_ui:
initialize_server_logger()
except Exception as e:
logger.error(str(e))
raise ImportError("Failed to run 'startup' event.")
def register_async_tasks():
def register_shutdown(app: FastAPI):
@app.on_event("shutdown")
async def on_shutdown():
cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
WALLET = get_wallet_class()
await WALLET.cleanup()
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
def initialize_server_logger():
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
serverlog_queue = asyncio.Queue()
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
async def update_websocket_serverlog():
while True:
msg = await serverlog_queue.get()
await websocketUpdater(super_user_hash, msg)
# server logs for websocket
if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger()
create_permanent_task(server_log_task)
create_permanent_task(update_websocket_serverlog)
logger.add(
lambda msg: serverlog_queue.put_nowait(msg),
format=Formatter().format,
)
def log_server_info():
logger.info("Starting LNbits")
logger.info(f"Version: {settings.version}")
logger.info(f"Baseurl: {settings.lnbits_baseurl}")
logger.info(f"Host: {settings.host}")
logger.info(f"Port: {settings.port}")
logger.info(f"Debug: {settings.debug}")
logger.info(f"Site title: {settings.lnbits_site_title}")
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
logger.info(f"Data folder: {settings.lnbits_data_folder}")
logger.info(f"Database: {get_db_vendor_name()}")
logger.info(f"Service fee: {settings.lnbits_service_fee}")
logger.info(f"Service fee max: {settings.lnbits_service_fee_max}")
logger.info(f"Service fee wallet: {settings.lnbits_service_fee_wallet}")
def get_db_vendor_name():
db_url = settings.lnbits_database_url
return (
"PostgreSQL"
if db_url and db_url.startswith("postgres://")
else (
"CockroachDB"
if db_url and db_url.startswith("cockroachdb://")
else "SQLite"
)
)
def register_async_tasks(app):
@app.route("/wallet/webhook")
async def webhook_listener():
return await webhook_handler()
@app.on_event("startup")
async def listeners():
create_permanent_task(check_pending_payments)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
register_task_listeners()
register_killswitch()
# await run_deferred_async() # calle: doesn't do anyting?
def register_exception_handlers(app: FastAPI):
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
logger.error(f"Exception: {str(exc)}")
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": f"Error: {str(exc)}"}
)
return JSONResponse(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
content={"detail": str(exc)},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
logger.error(f"RequestValidationError: {str(exc)}")
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
return template_renderer().TemplateResponse(
"error.html",
{"request": request, "err": f"Error: {str(exc)}"},
)
return JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
if (
request.headers
and "accept" in request.headers
and "text/html" in request.headers["accept"]
):
if exc.headers and "token-expired" in exc.headers:
response = RedirectResponse("/")
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.set_cookie(
"is_access_token_expired", "true", samesite="none", secure=True
)
return response
return template_renderer().TemplateResponse(
"error.html",
{
"request": request,
"err": f"HTTP Error {exc.status_code}: {exc.detail}",
},
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
def configure_logger() -> None:
logger.remove()
log_level: str = "DEBUG" if settings.debug else "INFO"
formatter = Formatter()
logger.add(sys.stdout, level=log_level, format=formatter.format)
if settings.enable_log_to_file:
logger.add(
Path(settings.lnbits_data_folder, "logs", "lnbits.log"),
rotation=settings.log_rotation,
retention=settings.log_retention,
level="INFO",
format=formatter.format,
)
logger.add(
Path(settings.lnbits_data_folder, "logs", "debug.log"),
rotation=settings.log_rotation,
retention=settings.log_retention,
level="DEBUG",
format=formatter.format,
)
logging.getLogger("uvicorn").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.access").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.error").handlers = [InterceptHandler()]
logging.getLogger("uvicorn.error").propagate = False
class Formatter:
def __init__(self):
self.padding = 0
self.minimal_fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | "
"<level>{message}</level>\n"
)
if settings.debug:
self.fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | "
"<level>{level: <4}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
"<level>{message}</level>\n"
)
else:
self.fmt = self.minimal_fmt
def format(self, record):
function = "{function}".format(**record)
if function == "emit": # uvicorn logs
return self.minimal_fmt
return self.fmt
class InterceptHandler(logging.Handler):
def emit(self, record):
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
logger.log(level, record.getMessage())
+94 -173
View File
@@ -1,10 +1,8 @@
import asyncio
import importlib
import sys
import time
from functools import wraps
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple
from urllib.parse import urlparse
import click
import httpx
@@ -12,34 +10,30 @@ from fastapi.exceptions import HTTPException
from loguru import logger
from packaging import version
from lnbits.core import db as core_db
from lnbits.core.crud import (
from lnbits.core.models import User
from lnbits.core.services import check_admin_settings
from lnbits.core.views.api import api_install_extension, api_uninstall_extension
from lnbits.settings import settings
from .core import db as core_db
from .core import migrations as core_migrations
from .core.crud import (
delete_accounts_no_wallets,
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_db_versions,
get_dbversions,
get_inactive_extensions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment,
)
from lnbits.core.helpers import is_valid_url, migrate_databases
from lnbits.core.models import Payment, PaymentState
from lnbits.core.models.extensions import (
from .core.helpers import migrate_extension_database, run_migration
from .db import COCKROACH, POSTGRES, SQLITE
from .extension_manager import (
CreateExtension,
ExtensionRelease,
InstallableExtension,
get_valid_extensions,
)
from lnbits.core.services import check_admin_settings
from lnbits.core.views.extension_api import (
api_install_extension,
api_uninstall_extension,
)
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
def coro(f):
@@ -79,7 +73,7 @@ def get_super_user() -> Optional[str]:
"Superuser id not found. Please check that the file "
+ f"'{superuser_file.absolute()}' exists and has read permissions."
)
with open(superuser_file) as file:
with open(superuser_file, "r") as file:
return file.readline()
@@ -119,12 +113,52 @@ def database_migrate():
loop.run_until_complete(migrate_databases())
async def db_migrate():
asyncio.create_task(migrate_databases())
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.info("✔️ All migrations done.")
@db.command("versions")
@coro
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_db_versions(conn))
click.echo(await get_dbversions(conn))
@db.command("cleanup-wallets")
@@ -146,40 +180,6 @@ async def database_cleanup_deleted_wallets():
await remove_deleted_wallets(conn)
@db.command("delete-wallet")
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
@coro
async def database_delete_wallet(wallet: str):
"""Mark wallet as deleted"""
async with core_db.connect() as conn:
count = await delete_wallet_by_id(wallet_id=wallet, conn=conn)
click.echo(f"Marked as deleted '{count}' rows.")
@db.command("delete-wallet-payment")
@click.option("-w", "--wallet", required=True, help="ID of wallet to be deleted.")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_delete_wallet_payment(wallet: str, checking_id: str):
"""Delete wallet payment"""
async with core_db.connect() as conn:
await delete_wallet_payment(
wallet_id=wallet, checking_id=checking_id, conn=conn
)
@db.command("mark-payment-pending")
@click.option("-c", "--checking-id", required=True, help="Payment checking Id.")
@coro
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn=conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts")
@click.argument("days", type=int, required=False)
@coro
@@ -191,84 +191,10 @@ async def database_cleanup_accounts(days: Optional[int] = None):
await delete_accounts_no_wallets(delta, conn)
@db.command("check-payments")
@click.option("-d", "--days", help="Maximum age of payments in days.")
@click.option("-l", "--limit", help="Maximum number of payments to be checked.")
@click.option("-w", "--wallet", help="Only check for this wallet.")
@click.option("-v", "--verbose", is_flag=True, help="Detailed log.")
@coro
async def check_invalid_payments(
days: Optional[int] = None,
limit: Optional[int] = None,
wallet: Optional[str] = None,
verbose: Optional[bool] = False,
):
"""Check payments that are settled in the DB but pending on the Funding Source"""
await check_admin_settings()
settled_db_payments = []
if verbose:
click.echo(f"Get Payments: days={days}, limit={limit}, wallet={wallet}")
async with core_db.connect() as conn:
delta = int(days) if days else 3 # default to 3 days
limit = int(limit) if limit else 1000
since = int(time.time()) - delta * 24 * 60 * 60
settled_db_payments = await get_payments(
complete=True,
incoming=True,
exclude_uncheckable=True,
since=since,
limit=limit,
wallet_id=wallet,
conn=conn,
)
click.echo("Settled Payments: " + str(len(settled_db_payments)))
wallets_module = importlib.import_module("lnbits.wallets")
wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
funding_source: Wallet = wallet_class()
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: list[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
click.echo(
f"Checking Payment: '{db_payment.checking_id}' for wallet"
+ f" '{db_payment.wallet_id}'."
)
payment_status = await funding_source.get_invoice_status(db_payment.checking_id)
if payment_status.pending:
invalid_payments.append(db_payment)
if db_payment.wallet_id not in invalid_wallets:
invalid_wallets[f"{db_payment.wallet_id}"] = [0, 0]
invalid_wallets[f"{db_payment.wallet_id}"][0] += 1
invalid_wallets[f"{db_payment.wallet_id}"][1] += db_payment.amount
click.echo(
"Invalid Payment: '"
+ " ".join(
[
db_payment.checking_id,
db_payment.wallet_id,
str(db_payment.amount / 1000).ljust(10),
db_payment.memo or "",
]
)
+ "'"
)
click.echo("Invalid Payments: " + str(len(invalid_payments)))
click.echo("\nInvalid Wallets: " + str(len(invalid_wallets)))
for w in invalid_wallets:
data = invalid_wallets[f"{w}"]
click.echo(" ".join([w, str(data[0]), str(data[1] / 1000).ljust(10)]))
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_inactive_extensions()
settings.lnbits_deactivated_extensions += inactive_extensions
@extensions.command("list")
@@ -280,15 +206,13 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
assert (
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})")
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.installed_release.version})")
@extensions.command("update")
@click.argument("extension", required=False)
@click.option("-a", "--all-extensions", is_flag=True, help="Update all extensions.")
@click.option("-a", "--all", is_flag=True, help="Update all extensions.")
@click.option(
"-i", "--repo-index", help="Select the index of the repository to be used."
)
@@ -315,7 +239,7 @@ async def extensions_list():
@coro
async def extensions_update(
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
all: Optional[bool] = False,
repo_index: Optional[str] = None,
source_repo: Optional[str] = None,
url: Optional[str] = None,
@@ -325,31 +249,26 @@ async def extensions_update(
Update extension to the latest version.
If an extension is not present it will be instaled.
"""
if not extension and not all_extensions:
if not extension and not all:
click.echo("Extension ID is required.")
click.echo("Or specify the '--all-extensions' flag to update all extensions")
click.echo("Or specify the '--all' flag to update all extensions")
return
if extension and all_extensions:
if extension and all:
click.echo("Only one of extension ID or the '--all' flag must be specified")
return
if url and not is_valid_url(url):
if url and not _is_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
if not await _can_run_operation(url):
return
upgrades_dir = settings.lnbits_extensions_upgrade_path
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
sys.path.append(str(upgrades_dir))
if extension:
await update_extension(extension, repo_index, source_repo, url, admin_user)
return
click.echo("Updating all extensions:")
click.echo("Updating all extensions...")
installed_extensions = await get_installed_extensions()
click.echo(f" {[e.id for e in installed_extensions]}")
updated_extensions = []
for e in installed_extensions:
try:
@@ -411,7 +330,7 @@ async def extensions_install(
):
"""Install a extension"""
click.echo(f"Installing {extension}... {repo_index}")
if url and not is_valid_url(url):
if url and not _is_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -439,7 +358,7 @@ async def extensions_uninstall(
"""Uninstall a extension"""
click.echo(f"Uninstalling '{extension}'...")
if url and not is_valid_url(url):
if url and not _is_url(url):
click.echo(f"Invalid '--url' option value: {url}")
return
@@ -452,7 +371,7 @@ async def extensions_uninstall(
click.echo(f"Failed to uninstall '{extension}' Error: '{ex.detail}'.")
return False, ex.detail
except Exception as ex:
click.echo(f"Failed to uninstall '{extension}': {ex!s}.")
click.echo(f"Failed to uninstall '{extension}': {str(ex)}.")
return False, str(ex)
@@ -471,17 +390,14 @@ async def install_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> tuple[bool, str]:
) -> Tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
if not release:
return False, "No release selected"
data = CreateExtension(
ext_id=extension,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
ext_id=extension, archive=release.archive, source_repo=release.source_repo
)
await _call_install_extension(data, url, admin_user)
click.echo(f"Extension '{extension}' ({release.version}) installed.")
@@ -490,7 +406,7 @@ async def install_extension(
click.echo(f"Failed to install '{extension}' Error: '{ex.detail}'.")
return False, ex.detail
except Exception as ex:
click.echo(f"Failed to install '{extension}': {ex!s}.")
click.echo(f"Failed to install '{extension}': {str(ex)}.")
return False, str(ex)
@@ -500,7 +416,7 @@ async def update_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> tuple[bool, str]:
) -> Tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension)
@@ -513,7 +429,7 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert (
installed_ext.meta and installed_ext.meta.installed_release
installed_ext.installed_release
), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo)
@@ -521,7 +437,7 @@ async def update_extension(
return False, "No release selected."
if (
release.version == installed_ext.installed_version
and release.source_repo == installed_ext.meta.installed_release.source_repo
and release.source_repo == installed_ext.installed_release.source_repo
):
click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date"
@@ -529,10 +445,7 @@ async def update_extension(
click.echo(f"Updating '{extension}' extension to version: {release.version }")
data = CreateExtension(
ext_id=extension,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
ext_id=extension, archive=release.archive, source_repo=release.source_repo
)
await _call_install_extension(data, url, admin_user)
@@ -542,7 +455,7 @@ async def update_extension(
click.echo(f"Failed to update '{extension}' Error: '{ex.detail}.")
return False, ex.detail
except Exception as ex:
click.echo(f"Failed to update '{extension}': {ex!s}.")
click.echo(f"Failed to update '{extension}': {str(ex)}.")
return False, str(ex)
@@ -565,7 +478,7 @@ async def _select_release(
return latest_repo_releases[source_repo]
if len(latest_repo_releases) == 1:
return latest_repo_releases[next(iter(latest_repo_releases.keys()))]
return latest_repo_releases[list(latest_repo_releases.keys())[0]]
repos = list(latest_repo_releases.keys())
repos.sort()
@@ -620,7 +533,7 @@ async def _call_install_extension(
)
resp.raise_for_status()
else:
await api_install_extension(data)
await api_install_extension(data, User(id="mock_id"))
async def _call_uninstall_extension(
@@ -634,7 +547,7 @@ async def _call_uninstall_extension(
)
resp.raise_for_status()
else:
await api_uninstall_extension(extension)
await api_uninstall_extension(extension, User(id="mock_id"))
async def _can_run_operation(url) -> bool:
@@ -653,7 +566,7 @@ async def _can_run_operation(url) -> bool:
elif url:
click.echo(
"The option '--url' has been provided,"
f" but no server found running at '{url}'"
+ f" but no server found runnint at '{url}'"
)
return False
@@ -668,3 +581,11 @@ async def _is_lnbits_started(url: Optional[str]):
return True
except Exception:
return False
def _is_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+8 -21
View File
@@ -1,43 +1,30 @@
from fastapi import APIRouter, FastAPI
from fastapi import APIRouter
from .db import core_app_extra, db
from .views.admin_api import admin_router
from .views.api import api_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.extension_api import extension_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
from .views.generic import generic_router, update_user_extension
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.public_api import public_router
from .views.tinyurl_api import tinyurl_router
from .views.user_api import users_router
from .views.wallet_api import wallet_router
from .views.webpush_api import webpush_router
from .views.websocket_api import websocket_router
# backwards compatibility for extensions
core_app = APIRouter(tags=["Core"])
def init_core_routers(app: FastAPI):
def init_core_routers(app):
app.include_router(core_app)
app.include_router(generic_router)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(public_router)
app.include_router(api_router)
app.include_router(node_router)
app.include_router(extension_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(payment_router)
app.include_router(wallet_router)
app.include_router(api_router)
app.include_router(websocket_router)
app.include_router(admin_router)
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(audit_router)
__all__ = ["core_app", "core_app_extra", "db"]
app.include_router(auth_router)
+1281
View File
File diff suppressed because it is too large Load Diff
-169
View File
@@ -1,169 +0,0 @@
from .audit import create_audit_entry
from .db_versions import (
delete_dbversion,
get_db_version,
get_db_versions,
update_migration_version,
)
from .extensions import (
create_installed_extension,
create_user_extension,
delete_installed_extension,
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
update_installed_extension,
update_installed_extension_state,
update_user_extension,
)
from .payments import (
DateTrunc,
check_internal,
create_payment,
delete_expired_invoices,
delete_wallet_payment,
get_latest_payments_by_extension,
get_payment,
get_payments,
get_payments_history,
get_payments_paginated,
get_standalone_payment,
get_wallet_payment,
is_internal_status_success,
mark_webhook_sent,
update_payment,
update_payment_checking_id,
update_payment_extra,
)
from .settings import (
create_admin_settings,
delete_admin_settings,
get_admin_settings,
get_super_settings,
update_admin_settings,
update_super_user,
)
from .tinyurl import create_tinyurl, delete_tinyurl, get_tinyurl, get_tinyurl_by_url
from .users import (
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_access_control_lists,
get_user_from_account,
update_account,
)
from .wallets import (
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,
)
from .webpush import (
create_webpush_subscription,
delete_webpush_subscription,
delete_webpush_subscriptions,
get_webpush_subscription,
get_webpush_subscriptions_for_user,
)
__all__ = [
# audit
"create_audit_entry",
# db_versions
"get_db_version",
"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_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_payment",
"get_payments",
"get_payments_history",
"get_payments_paginated",
"get_standalone_payment",
"get_wallet_payment",
"is_internal_status_success",
"mark_webhook_sent",
"update_payment",
"update_payment_checking_id",
"update_payment_extra",
# settings
"create_admin_settings",
"delete_admin_settings",
"get_admin_settings",
"get_super_settings",
"update_admin_settings",
"update_super_user",
# 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",
# webpush
"create_webpush_subscription",
"delete_webpush_subscription",
"delete_webpush_subscriptions",
"get_webpush_subscription",
"get_webpush_subscriptions_for_user",
]
-84
View File
@@ -1,84 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models import AuditEntry, AuditFilters
from lnbits.core.models.audit import AuditCountStat
from lnbits.db import Connection, Filters, Page
async def create_audit_entry(
entry: AuditEntry,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("audit", entry)
async def get_audit_entries(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AuditEntry]:
return await (conn or db).fetch_page(
"SELECT * from audit",
[],
{},
filters=filters,
model=AuditEntry,
)
async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""",
)
async def get_count_stats(
field: str,
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if field not in ["request_method", "component", "response_code"]:
return []
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""",
values=filters.values(),
model=AuditCountStat,
)
return data
async def get_long_duration_stats(
filters: Optional[Filters[AuditFilters]] = None,
conn: Optional[Connection] = None,
) -> list[AuditCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""",
values=filters.values(),
model=AuditCountStat,
)
return long_duration_paths
-39
View File
@@ -1,39 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.db import Connection
from ..models import DbVersion
async def get_db_version(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[DbVersion]:
return await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :ext_id",
{"ext_id": ext_id},
model=DbVersion,
)
async def get_db_versions(conn: Optional[Connection] = None) -> list[DbVersion]:
return await (conn or db).fetchall("SELECT * FROM dbversions", model=DbVersion)
async def update_migration_version(conn, db_name, version):
await (conn or db).execute(
"""
INSERT INTO dbversions (db, version) VALUES (:db, :version)
ON CONFLICT (db) DO UPDATE SET version = :version
""",
{"db": db_name, "version": version},
)
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = :ext
""",
{"ext": ext_id},
)
-137
View File
@@ -1,137 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from lnbits.core.models.extensions import (
InstallableExtension,
UserExtension,
)
from lnbits.db import Connection, Database
async def create_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).insert("installed_extensions", ext)
async def update_installed_extension(
ext: InstallableExtension,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update("installed_extensions", ext)
async def update_installed_extension_state(
*, ext_id: str, active: bool, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
UPDATE installed_extensions SET active = :active WHERE id = :ext
""",
{"ext": ext_id, "active": active},
)
async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"""
DELETE from installed_extensions WHERE id = :ext
""",
{"ext": ext_id},
)
async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> None:
row: dict = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = :id",
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
assert row, f"Extension '{ext_id}' db version cannot be found"
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
)
async def get_installed_extension(
ext_id: str, conn: Optional[Connection] = None
) -> Optional[InstallableExtension]:
extension = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = :id",
{"id": ext_id},
InstallableExtension,
)
return extension
async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
where = "WHERE active = :active" if active is not None else ""
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
f"SELECT * FROM installed_extensions {where}",
values,
model=InstallableExtension,
)
return all_extensions
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> Optional[UserExtension]:
return await (conn or db).fetchone(
"""
SELECT * FROM extensions
WHERE "user" = :user AND extension = :ext
""",
{"user": user_id, "ext": extension},
model=UserExtension,
)
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
) -> list[UserExtension]:
return await (conn or db).fetchall(
"""SELECT * FROM extensions WHERE "user" = :user""",
{"user": user_id},
model=UserExtension,
)
async def create_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
await (conn or db).insert("extensions", user_extension)
async def update_user_extension(
user_extension: UserExtension, conn: Optional[Connection] = None
) -> None:
where = """WHERE extension = :extension AND "user" = :user"""
await (conn or db).update("extensions", user_extension, where)
async def get_user_active_extensions_ids(
user_id: str, conn: Optional[Connection] = None
) -> list[str]:
exts = await (conn or db).fetchall(
"""
SELECT * FROM extensions WHERE "user" = :user AND active
""",
{"user": user_id},
UserExtension,
)
return [ext.extension for ext in exts]
-518
View File
@@ -1,518 +0,0 @@
from time import time
from typing import Optional, Tuple
from lnbits.core.crud.wallets import get_total_balance, get_wallet
from lnbits.core.db import db
from lnbits.core.models import PaymentState
from lnbits.db import Connection, DateTrunc, Filters, Page
from ..models import (
CreatePayment,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentWalletStats,
)
def update_payment_extra():
pass
async def get_payment(checking_id: str, conn: Optional[Connection] = None) -> Payment:
return await (conn or db).fetchone(
"SELECT * FROM apipayments WHERE checking_id = :checking_id",
{"checking_id": checking_id},
Payment,
)
async def get_standalone_payment(
checking_id_or_hash: str,
conn: Optional[Connection] = None,
incoming: Optional[bool] = False,
wallet_id: Optional[str] = None,
) -> Optional[Payment]:
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = {
"wallet_id": wallet_id,
"checking_id": checking_id_or_hash,
"hash": checking_id_or_hash,
}
if incoming:
clause = f"({clause}) AND amount > 0"
if wallet_id:
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""",
values,
Payment,
)
return row
async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
payment = await (conn or db).fetchone(
"""
SELECT *
FROM apipayments
WHERE wallet_id = :wallet AND payment_hash = :hash
""",
{"wallet": wallet_id, "hash": payment_hash},
Payment,
)
return payment
async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
f"""
SELECT * FROM apipayments
WHERE status = '{PaymentState.SUCCESS}'
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)}
""",
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
Payment,
)
async def get_payments_paginated(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
failed: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[Payment]:
"""
Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming.
"""
values: dict = {
"wallet_id": wallet_id,
"time": since,
}
clause: list[str] = []
if since is not None:
clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id:
clause.append("wallet_id = :wallet_id")
if complete and pending:
clause.append(
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
)
elif complete:
clause.append(
f"""
(
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
"""
)
elif pending:
clause.append(f"status = '{PaymentState.PENDING}'")
elif failed:
clause.append(f"status = '{PaymentState.FAILED}'")
if outgoing and incoming:
pass
elif outgoing:
clause.append("amount < 0")
elif incoming:
clause.append("amount > 0")
else:
pass
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
clause.append("checking_id NOT LIKE 'temp_%'")
clause.append("checking_id NOT LIKE 'internal_%'")
return await (conn or db).fetch_page(
"SELECT * FROM apipayments",
clause,
values,
filters=filters,
model=Payment,
)
async def get_payments(
*,
wallet_id: Optional[str] = None,
complete: bool = False,
pending: bool = False,
outgoing: bool = False,
incoming: bool = False,
since: Optional[int] = None,
exclude_uncheckable: bool = False,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[Payment]:
"""
Filters payments to be returned by complete | pending | outgoing | incoming.
"""
filters = filters or Filters()
filters.sortby = filters.sortby or "time"
filters.direction = filters.direction or "desc"
filters.limit = limit or filters.limit
filters.offset = offset or filters.offset
page = await get_payments_paginated(
wallet_id=wallet_id,
complete=complete,
pending=pending,
outgoing=outgoing,
incoming=incoming,
since=since,
exclude_uncheckable=exclude_uncheckable,
filters=filters,
conn=conn,
)
return page.data
async def get_payments_status_count() -> PaymentsStatusCount:
empty_page: Filters = Filters(limit=0)
in_payments = await get_payments_paginated(incoming=True, filters=empty_page)
out_payments = await get_payments_paginated(outgoing=True, filters=empty_page)
pending_payments = await get_payments_paginated(pending=True, filters=empty_page)
failed_payments = await get_payments_paginated(failed=True, filters=empty_page)
return PaymentsStatusCount(
incoming=in_payments.total,
outgoing=out_payments.total,
pending=pending_payments.total,
failed=failed_payments.total,
)
async def delete_expired_invoices(
conn: Optional[Connection] = None,
) -> None:
# first we delete all invoices older than one month
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""",
{"delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""",
{"now": int(time())},
)
async def create_payment(
checking_id: str,
data: CreatePayment,
status: PaymentState = PaymentState.PENDING,
conn: Optional[Connection] = None,
) -> Payment:
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
extra = data.extra or {}
payment = Payment(
checking_id=checking_id,
status=status,
wallet_id=data.wallet_id,
payment_hash=data.payment_hash,
bolt11=data.bolt11,
amount=data.amount_msat,
memo=data.memo,
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=data.fee,
tag=extra.get("tag", None),
extra=extra,
)
await (conn or db).insert("apipayments", payment)
return payment
async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
{"new_id": new_checking_id, "old_id": checking_id},
)
async def update_payment(
payment: Payment,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
async def get_payments_history(
wallet_id: Optional[str] = None,
group: DateTrunc = "day",
filters: Optional[Filters] = None,
) -> list[PaymentHistoryPoint]:
if not filters:
filters = Filters()
date_trunc = db.datetime_grouping(group)
values = {
"wallet_id": wallet_id,
}
# count outgoing payments if they are still pending
where = [
f"""
wallet_id = :wallet_id AND (
status = '{PaymentState.SUCCESS}'
OR (amount < 0 AND status = '{PaymentState.PENDING}')
)
"""
]
transactions: list[dict] = await db.fetchall(
f"""
SELECT {date_trunc} date,
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
FROM apipayments
{filters.where(where)}
GROUP BY date
ORDER BY date DESC
""",
filters.values(values),
)
if wallet_id:
wallet = await get_wallet(wallet_id)
if wallet:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
else:
balance = await get_total_balance()
# since we dont know the balance at the starting point,
# we take the current balance and walk backwards
results: list[PaymentHistoryPoint] = []
for row in transactions:
results.insert(
0,
PaymentHistoryPoint(
balance=balance,
date=row.get("date", 0),
income=row.get("income", 0),
spending=row.get("spending", 0),
),
)
balance -= row.get("income", 0) - row.get("spending", 0)
return results
async def get_payment_count_stats(
field: PaymentCountField,
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
query=f"""
SELECT {field} as field, count(*) as total
FROM apipayments
{clause}
GROUP BY {field}
ORDER BY {field}
""",
values=filters.values(),
model=PaymentCountStat,
)
return data
async def get_daily_stats(
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> Tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
in_clause = filters.where(
["(apipayments.status = 'success' AND apipayments.amount > 0)"]
)
out_clause = filters.where(
["(apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)"]
)
date_trunc = db.datetime_grouping("day")
query = """
SELECT {date_trunc} date,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance,
ABS(SUM(apipayments.fee)) as fee,
COUNT(*) as payments_count
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clause}
AND (wallets.deleted = false OR wallets.deleted is NULL)
GROUP BY date
ORDER BY date ASC
"""
data_in = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=in_clause),
values=filters.values(),
model=PaymentDailyStats,
)
data_out = await (conn or db).fetchall(
query=query.format(date_trunc=date_trunc, clause=out_clause),
values=filters.values(),
model=PaymentDailyStats,
)
return data_in, data_out
async def get_wallets_stats(
filters: Optional[Filters[PaymentFilters]] = None,
conn: Optional[Connection] = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
where_stmts = [
"(wallets.deleted = false OR wallets.deleted is NULL)",
"""
(
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (
apipayments.status IN ('success', 'pending')
AND apipayments.amount < 0
)
)
""",
]
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
query=f"""
SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name,
MAX(wallets.user) AS user_id,
COUNT(*) as payments_count,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
{clauses}
GROUP BY apipayments.wallet_id
ORDER BY payments_count
""",
values=filters.values(),
model=PaymentWalletStats,
)
return data
async def delete_wallet_payment(
checking_id: str, wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = :checking_id AND wallet = :wallet",
{"checking_id": checking_id, "wallet": wallet_id},
)
async def check_internal(
payment_hash: str, conn: Optional[Connection] = None
) -> Optional[Payment]:
"""
Returns the checking_id of the internal payment if it exists,
otherwise None
"""
return await (conn or db).fetchone(
f"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
""",
{"hash": payment_hash},
Payment,
)
async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
Returns True if the internal payment was found and is successful,
"""
payment = await (conn or db).fetchone(
"""
SELECT * FROM apipayments
WHERE payment_hash = :payment_hash AND amount > 0
""",
{"payment_hash": payment_hash},
Payment,
)
if not payment:
return False
return payment.status == PaymentState.SUCCESS.value
async def mark_webhook_sent(payment_hash: str, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = :status
WHERE payment_hash = :hash
""",
{"status": status, "hash": payment_hash},
)
-122
View File
@@ -1,122 +0,0 @@
import json
from typing import Any, Optional
from loguru import logger
from lnbits.core.db import db
from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
SettingsField,
SuperSettings,
settings,
)
async def get_super_settings() -> Optional[SuperSettings]:
data = await get_settings_by_tag("core")
if data:
super_user = await get_settings_field("super_user")
super_user_id = super_user.value if super_user else None
settings_dict = {"super_user": super_user_id, **data}
return dict_to_model(settings_dict, SuperSettings)
return None
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
sets = await get_super_settings()
if not sets:
return None
row_dict = dict(sets)
row_dict.pop("super_user")
row_dict.pop("auth_all_methods")
admin_settings = AdminSettings(
is_super_user=is_super_user,
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
**row_dict,
)
return admin_settings
async def update_admin_settings(
data: EditableSettings, tag: Optional[str] = "core"
) -> None:
editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True))
for key, value in editable_settings.items():
try:
await set_settings_field(key, value, tag)
except Exception as exc:
logger.warning(exc)
logger.warning(f"Failed to update settings for '{tag}.{key}'.")
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
assert settings, "updated super_user settings could not be retrieved"
return settings
async def delete_admin_settings(tag: Optional[str] = "core") -> None:
await db.execute("DELETE FROM settings WHERE tag = :tag", {"tag": tag})
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
data = {"super_user": super_user, **new_settings}
for key, value in data.items():
await set_settings_field(key, value)
settings = await get_super_settings()
assert settings, "created admin settings could not be retrieved"
return settings
async def get_settings_field(
id_: str, tag: Optional[str] = "core"
) -> Optional[SettingsField]:
row: dict = await db.fetchone(
"""
SELECT * FROM system_settings
WHERE id = :id AND tag = :tag
""",
{"id": id_, "tag": tag},
)
if not row:
return None
return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
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
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
ON CONFLICT (id, tag) DO UPDATE SET value = :value
""",
{"id": id_, "value": value, "tag": tag or "core"},
)
async def get_settings_by_tag(tag: str) -> Optional[dict[str, Any]]:
rows: list[dict] = await db.fetchall(
"SELECT * FROM system_settings WHERE tag = :tag", {"tag": tag}
)
if len(rows) == 0:
return None
data: dict[str, Any] = {}
for row in rows:
try:
data[row["id"]] = json.loads(row["value"]) if row["value"] else None
except Exception as _:
logger.warning(
f"""Failed to load settings value for '{tag}.{row["id"]}'."""
)
data.pop("super_user")
return data
-42
View File
@@ -1,42 +0,0 @@
from typing import Optional
import shortuuid
from lnbits.core.db import db
from ..models import TinyURL
async def create_tinyurl(domain: str, endless: bool, wallet: str):
tinyurl_id = shortuuid.uuid()[:8]
await db.execute(
"""
INSERT INTO tiny_url (id, url, endless, wallet)
VALUES (:tinyurl, :domain, :endless, :wallet)
""",
{"tinyurl": tinyurl_id, "domain": domain, "endless": endless, "wallet": wallet},
)
return await get_tinyurl(tinyurl_id)
async def get_tinyurl(tinyurl_id: str) -> Optional[TinyURL]:
return await db.fetchone(
"SELECT * FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
TinyURL,
)
async def get_tinyurl_by_url(url: str) -> list[TinyURL]:
return await db.fetchall(
"SELECT * FROM tiny_url WHERE url = :url",
{"url": url},
TinyURL,
)
async def delete_tinyurl(tinyurl_id: str):
await db.execute(
"DELETE FROM tiny_url WHERE id = :tinyurl",
{"tinyurl": tinyurl_id},
)
-211
View File
@@ -1,211 +0,0 @@
from datetime import datetime, timezone
from time import time
from typing import Any, Optional
from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import get_wallets
from lnbits.core.db import db
from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page
from ..models import (
Account,
AccountFilters,
AccountOverview,
User,
)
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
) -> Account:
if account:
account.validate_fields()
else:
now = datetime.now(timezone.utc)
account = Account(id=uuid4().hex, created_at=now, updated_at=now)
await (conn or db).insert("accounts", account)
return account
async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc)
await db.update("accounts", account)
return account
async def delete_account(user_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"DELETE from accounts WHERE id = :user",
{"user": user_id},
)
async def get_accounts(
filters: Optional[Filters[AccountFilters]] = None,
conn: Optional[Connection] = None,
) -> Page[AccountOverview]:
where_clauses = []
values: dict[str, Any] = {}
# Make wallet filter explicit
wallet_filter = (
next((f for f in filters.filters if f.field == "wallet_id"), None)
if filters
else None
)
if filters and wallet_filter and wallet_filter.values:
where_clauses.append("wallets.id = :wallet_id")
values = {**values, "wallet_id": next(iter(wallet_filter.values.values()))}
filters.filters = [f for f in filters.filters if f.field != "wallet_id"]
return await (conn or db).fetch_page(
"""
SELECT
accounts.id,
accounts.username,
accounts.email,
accounts.pubkey,
SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat,
SUM((
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
)) as transaction_count,
(
SELECT COUNT(*) FROM wallets WHERE wallets.user = accounts.id
) as wallet_count,
MAX((
SELECT time FROM apipayments
WHERE wallet_id = wallets.id ORDER BY time DESC LIMIT 1
)) as last_payment
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
""",
where_clauses,
values,
filters=filters,
model=AccountOverview,
group_by=["accounts.id"],
)
async def get_account(
user_id: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(user_id) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE id = :id",
{"id": user_id},
Account,
)
async def delete_accounts_no_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
SELECT wallets.id FROM wallets WHERE wallets.user = accounts.id
) AND (
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""",
{"delta": delta},
)
async def get_account_by_username(
username: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(username) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE username = :username",
{"username": username},
Account,
)
async def get_account_by_pubkey(
pubkey: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE pubkey = :pubkey",
{"pubkey": pubkey},
Account,
)
async def get_account_by_email(
email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
if len(email) == 0:
return None
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :email",
{"email": email},
Account,
)
async def get_account_by_username_or_email(
username_or_email: str, conn: Optional[Connection] = None
) -> Optional[Account]:
return await (conn or db).fetchone(
"SELECT * FROM accounts WHERE email = :value or username = :value",
{"value": username_or_email},
Account,
)
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
account = await get_account(user_id, conn)
if not account:
return None
return await get_user_from_account(account, conn)
async def get_user_from_account(
account: Account, conn: Optional[Connection] = None
) -> Optional[User]:
extensions = await get_user_active_extensions_ids(account.id, conn)
wallets = await get_wallets(account.id, False, conn=conn)
return User(
id=account.id,
email=account.email,
username=account.username,
pubkey=account.pubkey,
extra=account.extra,
created_at=account.created_at,
updated_at=account.updated_at,
extensions=extensions,
wallets=wallets,
admin=account.is_admin,
super_user=account.is_super_user,
has_password=account.password_hash is not None,
)
async def update_user_access_control_list(user_acls: UserAcls):
user_acls.updated_at = datetime.now(timezone.utc)
await db.update("accounts", user_acls)
async def get_user_access_control_lists(
user_id: str, conn: Optional[Connection] = None
) -> UserAcls:
user_acls = await (conn or db).fetchone(
"SELECT id, access_control_list FROM accounts WHERE id = :id",
{"id": user_id},
UserAcls,
)
return user_acls or UserAcls(id=user_id)
-164
View File
@@ -1,164 +0,0 @@
from datetime import datetime, timezone
from time import time
from typing import Optional
from uuid import uuid4
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.settings import settings
from ..models import Wallet
async def create_wallet(
*,
user_id: str,
wallet_name: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Wallet:
wallet_id = uuid4().hex
wallet = Wallet(
id=wallet_id,
name=wallet_name or settings.lnbits_default_wallet_name,
user=user_id,
adminkey=uuid4().hex,
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
)
await (conn or db).insert("wallets", wallet)
return wallet
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
async def delete_wallet(
*,
user_id: str,
wallet_id: str,
deleted: bool = True,
conn: Optional[Connection] = None,
) -> None:
now = int(time())
await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""",
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
async def force_delete_wallet(
wallet_id: str, conn: Optional[Connection] = None
) -> None:
await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id},
)
async def delete_wallet_by_id(
wallet_id: str, conn: Optional[Connection] = None
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""",
{"wallet": wallet_id, "now": now},
)
return result.rowcount
async def remove_deleted_wallets(conn: Optional[Connection] = None) -> None:
await (conn or db).execute("DELETE FROM wallets WHERE deleted = true")
async def delete_unused_wallets(
time_delta: int,
conn: Optional[Connection] = None,
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
"""
DELETE FROM wallets
WHERE (
SELECT COUNT(*) FROM apipayments WHERE wallet_id = wallets.id
) = 0 AND (
(updated_at is null AND created_at < :delta)
OR updated_at < :delta
)
""",
{"delta": delta},
)
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchone(
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,
)
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
return await (conn or db).fetchall(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user {where}
""",
{"user": user_id, "deleted": deleted},
Wallet,
)
async def get_wallets_count():
result = await db.execute("SELECT COUNT(*) as count FROM wallets")
row = result.mappings().first()
return row.get("count", 0)
async def get_wallet_for_key(
key: str,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
return await (conn or db).fetchone(
"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)
AS balance_msat FROM wallets
WHERE (adminkey = :key OR inkey = :key) AND deleted = false
""",
{"key": key},
Wallet,
)
async def get_total_balance(conn: Optional[Connection] = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first()
return row.get("balance", 0) or 0
-59
View File
@@ -1,59 +0,0 @@
from typing import Optional
from lnbits.core.db import db
from ..models import WebPushSubscription
async def get_webpush_subscription(
endpoint: str, user: str
) -> Optional[WebPushSubscription]:
return await db.fetchone(
"""
SELECT * FROM webpush_subscriptions
WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
WebPushSubscription,
)
async def get_webpush_subscriptions_for_user(user: str) -> list[WebPushSubscription]:
return await db.fetchall(
"""SELECT * FROM webpush_subscriptions WHERE "user" = :user""",
{"user": user},
WebPushSubscription,
)
async def create_webpush_subscription(
endpoint: str, user: str, data: str, host: str
) -> WebPushSubscription:
await db.execute(
"""
INSERT INTO webpush_subscriptions (endpoint, "user", data, host)
VALUES (:endpoint, :user, :data, :host)
""",
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved"
return subscription
async def delete_webpush_subscription(endpoint: str, user: str) -> int:
resp = await db.execute(
"""
DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint AND "user" = :user
""",
{"endpoint": endpoint, "user": user},
)
return resp.rowcount
async def delete_webpush_subscriptions(endpoint: str) -> int:
resp = await db.execute(
"DELETE FROM webpush_subscriptions WHERE endpoint = :endpoint",
{"endpoint": endpoint},
)
return resp.rowcount
+35 -87
View File
@@ -1,52 +1,42 @@
import importlib
import re
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
import httpx
from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.db import Connection
from lnbits.extension_manager import Extension
from lnbits.settings import settings
from .crud import update_migration_version
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
async def migrate_extension_database(ext: Extension, current_version):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc:
logger.error(exc)
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
except ImportError as e:
logger.error(e)
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
async def run_migration(
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
db: Connection, migrations_module: Any, db_name: str, current_version: int
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in list(migrations_module.__dict__.items()):
for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key)
if match:
version = int(match.group(1))
if not current_version or version > current_version.version:
if version > current_version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -58,73 +48,31 @@ async def run_migration(
await update_migration_version(conn, db_name, version)
async def stop_extension_background_work(
ext_id: str, user: str, access_token: Optional[str] = None
):
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extensions SHOULD expose a DELETE enpoint at the root level of their API.
"""
async with httpx.AsyncClient() as client:
try:
url = f"http://{settings.host}:{settings.port}/{ext_id}/api/v1?usr={user}"
headers = (
{"Authorization": "Bearer " + access_token} if access_token else None
)
resp = await client.delete(url=url, headers=headers)
resp.raise_for_status()
except Exception as ex:
logger.warning(ex)
def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits")
try:
int(user_id, 16)
except Exception as exc:
raise ValueError("Invalid hex string for User ID.") from exc
except Exception:
raise ValueError("Invalid hex string for User ID.")
return UUID(hex=user_id[:32], version=4)
async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
async def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
async with core_db.connect() as conn:
exists = False
if conn.type == SQLITE:
exists = await conn.fetchone(
"SELECT * FROM sqlite_master WHERE type='table' AND name='dbversions'"
)
elif conn.type in {POSTGRES, COCKROACH}:
exists = await conn.fetchone(
"SELECT * FROM information_schema.tables WHERE table_schema = 'public'"
" AND table_name = 'dbversions'"
)
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.info("✔️ All migrations done.")
def is_valid_url(url):
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except ValueError:
return False
+74 -314
View File
@@ -1,15 +1,13 @@
import json
import datetime
from time import time
from typing import Any
from loguru import logger
from sqlalchemy.exc import OperationalError
from lnbits import bolt11
from lnbits.db import Connection
async def m000_create_migrations_table(db: Connection):
async def m000_create_migrations_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS dbversions (
@@ -20,7 +18,7 @@ async def m000_create_migrations_table(db: Connection):
)
async def m001_initial(db: Connection):
async def m001_initial(db):
"""
Initial LNbits tables.
"""
@@ -89,7 +87,7 @@ async def m001_initial(db: Connection):
)
async def m002_add_fields_to_apipayments(db: Connection):
async def m002_add_fields_to_apipayments(db):
"""
Adding fields to apipayments for better accounting,
and renaming payhash to checking_id since that is what it really is.
@@ -102,8 +100,9 @@ async def m002_add_fields_to_apipayments(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
result = await db.execute("SELECT * FROM apipayments")
rows = result.mappings().all()
import json
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
for row in rows:
if not row["memo"] or not row["memo"].startswith("#"):
continue
@@ -114,15 +113,15 @@ async def m002_add_fields_to_apipayments(db: Connection):
new = row["memo"][len(prefix) :]
await db.execute(
"""
UPDATE apipayments SET extra = :extra, memo = :memo1
WHERE checking_id = :checking_id AND memo = :memo2
UPDATE apipayments SET extra = ?, memo = ?
WHERE checking_id = ? AND memo = ?
""",
{
"extra": json.dumps({"tag": ext}),
"memo1": new,
"checking_id": row["checking_id"],
"memo2": row["memo"],
},
(
json.dumps({"tag": ext}),
new,
row["checking_id"],
row["memo"],
),
)
break
except OperationalError:
@@ -133,7 +132,7 @@ async def m002_add_fields_to_apipayments(db: Connection):
pass
async def m003_add_invoice_webhook(db: Connection):
async def m003_add_invoice_webhook(db):
"""
Special column for webhook endpoints that can be assigned
to each different invoice.
@@ -143,7 +142,7 @@ async def m003_add_invoice_webhook(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
async def m004_ensure_fees_are_always_negative(db: Connection):
async def m004_ensure_fees_are_always_negative(db):
"""
Use abs() so wallet backends don't have to care about the sign of the fees.
"""
@@ -168,7 +167,7 @@ async def m004_ensure_fees_are_always_negative(db: Connection):
)
async def m005_balance_check_balance_notify(db: Connection):
async def m005_balance_check_balance_notify(db):
"""
Keep track of balanceCheck-enabled lnurl-withdrawals to be consumed by an
LNbits wallet and of balanceNotify URLs supplied by users to empty their wallets.
@@ -198,7 +197,7 @@ async def m005_balance_check_balance_notify(db: Connection):
)
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
async def m006_add_invoice_expiry_to_apipayments(db):
"""
Adds invoice expiry column to apipayments.
"""
@@ -208,23 +207,24 @@ async def m006_add_invoice_expiry_to_apipayments(db: Connection):
pass
async def m007_set_invoice_expiries(db: Connection):
async def m007_set_invoice_expiries(db):
"""
Precomputes invoice expiry for existing pending incoming payments.
"""
try:
result = await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
rows = result.mappings().all()
rows = await (
await db.execute(
f"""
SELECT bolt11, checking_id
FROM apipayments
WHERE pending = true
AND amount > 0
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
)
).fetchall()
if len(rows):
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
@@ -236,17 +236,22 @@ async def m007_set_invoice_expiries(db: Connection):
if invoice.expiry is None:
continue
expiration_date = invoice.date + invoice.expiry
expiration_date = datetime.datetime.fromtimestamp(
invoice.date + invoice.expiry
)
logger.info(
f"Migration: {i+1}/{len(rows)} setting expiry of invoice"
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
"""
UPDATE apipayments SET expiry = ?
WHERE checking_id = ? AND amount > 0
""",
{"expiry": expiration_date, "checking_id": checking_id},
(
db.datetime_to_timestamp(expiration_date),
checking_id,
),
)
except Exception:
continue
@@ -258,7 +263,7 @@ async def m007_set_invoice_expiries(db: Connection):
pass
async def m008_create_admin_settings_table(db: Connection):
async def m008_create_admin_settings_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -269,7 +274,7 @@ async def m008_create_admin_settings_table(db: Connection):
)
async def m009_create_tinyurl_table(db: Connection):
async def m009_create_tinyurl_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS tiny_url (
@@ -283,7 +288,7 @@ async def m009_create_tinyurl_table(db: Connection):
)
async def m010_create_installed_extensions_table(db: Connection):
async def m010_create_installed_extensions_table(db):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS installed_extensions (
@@ -300,7 +305,7 @@ async def m010_create_installed_extensions_table(db: Connection):
)
async def m011_optimize_balances_view(db: Connection):
async def m011_optimize_balances_view(db):
"""
Make the calculation of the balance a single aggregation
over the payments table instead of 2.
@@ -317,7 +322,7 @@ async def m011_optimize_balances_view(db: Connection):
)
async def m012_add_currency_to_wallet(db: Connection):
async def m012_add_currency_to_wallet(db):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN currency TEXT
@@ -325,7 +330,7 @@ async def m012_add_currency_to_wallet(db: Connection):
)
async def m013_add_deleted_to_wallets(db: Connection):
async def m013_add_deleted_to_wallets(db):
"""
Adds deleted column to wallets.
"""
@@ -337,39 +342,34 @@ async def m013_add_deleted_to_wallets(db: Connection):
pass
async def m014_set_deleted_wallets(db: Connection):
async def m014_set_deleted_wallets(db):
"""
Sets deleted column to wallets.
"""
try:
result = await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
rows = result.mappings().all()
rows = await (
await db.execute(
"""
SELECT *
FROM wallets
WHERE user LIKE 'del:%'
AND adminkey LIKE 'del:%'
AND inkey LIKE 'del:%'
"""
)
).fetchall()
for row in rows:
try:
user = row["user"].split(":")[1]
adminkey = row["adminkey"].split(":")[1]
inkey = row["inkey"].split(":")[1]
user = row[2].split(":")[1]
adminkey = row[3].split(":")[1]
inkey = row[4].split(":")[1]
await db.execute(
"""
UPDATE wallets SET
"user" = :user, adminkey = :adminkey, inkey = :inkey, deleted = true
WHERE id = :wallet
UPDATE wallets SET user = ?, adminkey = ?, inkey = ?, deleted = true
WHERE id = ?
""",
{
"user": user,
"adminkey": adminkey,
"inkey": inkey,
"wallet": row.get("id"),
},
(user, adminkey, inkey, row[0]),
)
except Exception:
continue
@@ -381,7 +381,7 @@ async def m014_set_deleted_wallets(db: Connection):
pass
async def m015_create_push_notification_subscriptions_table(db: Connection):
async def m015_create_push_notification_subscriptions_table(db):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
@@ -396,7 +396,7 @@ async def m015_create_push_notification_subscriptions_table(db: Connection):
)
async def m016_add_username_column_to_accounts(db: Connection):
async def m016_add_username_column_to_accounts(db):
"""
Adds username column to accounts.
"""
@@ -407,7 +407,7 @@ async def m016_add_username_column_to_accounts(db: Connection):
pass
async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
"""
Adds created_at and updated_at column to accounts and wallets.
"""
@@ -455,259 +455,19 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
now = int(time())
await db.execute(
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
UPDATE wallets SET created_at = {db.timestamp_placeholder}
WHERE created_at IS NULL
""",
{"now": now},
(now,),
)
await db.execute(
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
UPDATE accounts SET created_at = {db.timestamp_placeholder}
WHERE created_at IS NULL
""",
{"now": now},
(now,),
)
except OperationalError as exc:
logger.error(f"Migration 17 failed: {exc}")
pass
async def m018_balances_view_exclude_deleted(db: Connection):
"""
Make deleted wallets not show up in the balances view.
"""
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM apipayments
LEFT JOIN wallets ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0)
GROUP BY wallet
"""
)
async def m019_balances_view_based_on_wallets(db: Connection):
"""
Make deleted wallets not show up in the balances view.
Important for querying whole lnbits balances.
"""
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND ((apipayments.pending = false AND apipayments.amount > 0)
OR apipayments.amount < 0)
GROUP BY apipayments.wallet
"""
)
async def m020_add_column_column_to_user_extensions(db: Connection):
"""
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")
async def m021_add_success_failed_to_apipayments(db: Connection):
"""
Adds success and failed columns to apipayments.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN status TEXT DEFAULT 'pending'")
# set all not pending to success true, failed payments were deleted until now
await db.execute("UPDATE apipayments SET status = 'success' WHERE NOT pending")
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet
"""
)
async def m022_add_pubkey_to_accounts(db: Connection):
"""
Adds pubkey column to accounts.
"""
try:
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
async def m023_add_column_column_to_apipayments(db: Connection):
"""
renames hash to payment_hash and drops unused index
"""
await db.execute("DROP INDEX by_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db: Connection):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m025_refresh_view(db: Connection):
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet_id
"""
)
async def m026_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN tag TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN extension TEXT")
await db.execute("ALTER TABLE apipayments ADD COLUMN created_at TIMESTAMP")
await db.execute("ALTER TABLE apipayments ADD COLUMN updated_at TIMESTAMP")
async def m027_update_apipayments_data(db: Connection):
result = None
try:
result = await db.execute("SELECT * FROM apipayments LIMIT 100")
except Exception as exc:
logger.warning("Could not select, trying again after cache cleared.")
logger.debug(exc)
await db.execute("COMMIT")
offset = 0
limit = 1000
payments: list[dict[Any, Any]] = []
logger.info("Updating payments")
while len(payments) > 0 or offset == 0:
logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute(
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
)
payments = result.mappings().all()
logger.info(f"Payments count: {len(payments)}")
for payment in payments:
tag = None
created_at = payment.get("time")
if payment.get("extra"):
extra = json.loads(str(payment.get("extra")))
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""",
{
"tag": tag,
"created_at": created_at,
"checking_id": payment.get("checking_id"),
},
)
offset += limit
logger.info("Payments updated")
async def m028_update_settings(db: Connection):
await db.execute(
"""
CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY,
value TEXT,
tag TEXT NOT NULL DEFAULT 'core',
UNIQUE (id, tag)
);
"""
)
async def _insert_key_value(id_: str, value: Any):
await db.execute(
"""
INSERT INTO system_settings (id, value, tag)
VALUES (:id, :value, :tag)
""",
{"id": id_, "value": json.dumps(value), "tag": "core"},
)
row: dict = await db.fetchone("SELECT * FROM settings")
if row:
await _insert_key_value("super_user", row["super_user"])
editable_settings = json.loads(row["editable_settings"])
for key, value in editable_settings.items():
await _insert_key_value(key, value)
await db.execute("drop table settings")
async def m029_create_audit_table(db: Connection):
await db.execute(
f"""
CREATE TABLE IF NOT EXISTS audit (
component TEXT,
ip_address TEXT,
user_id TEXT,
path TEXT,
request_type TEXT,
request_method TEXT,
request_details TEXT,
response_code TEXT,
duration REAL NOT NULL,
delete_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
async def m030_add_user_api_tokens_column(db: Connection):
await db.execute(
"""
ALTER TABLE accounts ADD COLUMN access_control_list TEXT
"""
)
async def m031_add_color_and_icon_to_wallets(db: Connection):
"""
Adds icon and color columns to wallets.
"""
await db.execute("ALTER TABLE wallets ADD COLUMN extra TEXT")
+423
View File
@@ -0,0 +1,423 @@
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from enum import Enum
from sqlite3 import Row
from typing import Callable, Dict, List, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.wallets import get_wallet_class
from lnbits.wallets.base import PaymentStatus
class Wallet(BaseModel):
id: str
name: str
user: str
adminkey: str
inkey: str
currency: Optional[str]
balance_msat: int
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class WalletType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
wallet_type: WalletType
wallet: Wallet
class UserConfig(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
class User(BaseModel):
id: str
email: Optional[str] = None
username: Optional[str] = None
extensions: List[str] = []
wallets: List[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
@property
def wallet_ids(self) -> List[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class CreateUser(BaseModel):
email: Optional[str] = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
class UpdateUserPassword(BaseModel):
user_id: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
password_old: Optional[str] = Query(default=None, min_length=8, max_length=50)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class Payment(FromRowModel):
checking_id: str
pending: bool
amount: int
fee: int
memo: Optional[str]
time: int
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[float]
extra: Dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
@classmethod
def from_row(cls, row: Row):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
pending=row["pending"],
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("internal_")
async def update_status(
self,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
conn=conn,
)
async def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
self.pending = pending
await update_payment_status(self.checking_id, pending)
async def check_status(
self,
conn: Optional[Connection] = None,
) -> PaymentStatus:
if self.is_uncheckable:
return PaymentStatus(None)
logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} "
f"pending payment {self.checking_id}"
)
WALLET = get_wallet_class()
if self.is_out:
status = await WALLET.get_payment_status(self.checking_id)
else:
status = await WALLET.get_invoice_status(self.checking_id)
logger.debug(f"Status: {status}")
if self.is_in and status.pending and self.is_expired and self.expiry:
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
logger.debug(
f"Deleting expired incoming pending payment {self.checking_id}: "
f"expired {expiration_date}"
)
await self.delete(conn)
# wait at least 15 minutes before deleting failed outgoing payments
elif self.is_out and status.failed:
if self.time + 900 < int(time.time()):
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete(conn)
else:
logger.warning(
f"Tried to delete outgoing payment {self.checking_id}: "
"skipping because it's not old enough"
)
elif not status.pending:
logger.info(
f"Marking '{'in' if self.is_in else 'out'}' "
f"{self.checking_id} as not pending anymore: {status}"
)
await self.update_status(status, conn=conn)
return status
async def delete(self, conn: Optional[Connection] = None) -> None:
from .crud import delete_wallet_payment
await delete_wallet_payment(self.checking_id, self.wallet_id, conn=conn)
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount"]
checking_id: str
amount: int
fee: int
memo: Optional[str]
time: datetime.datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
extra: Dict = {}
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
income: int
spending: int
balance: int
class BalanceCheck(BaseModel):
wallet: str
service: str
url: str
@classmethod
def from_row(cls, row: Row):
return cls(wallet=row["wallet"], service=row["service"], url=row["url"])
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class DecodePayment(BaseModel):
data: str
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
lnurl_callback: Optional[str] = None
lnurl_balance_check: Optional[str] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
class CreateTopup(BaseModel):
id: str
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: str
-109
View File
@@ -1,109 +0,0 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .misc import (
BalanceDelta,
Callback,
ConversionData,
CoreAppExtra,
DbVersion,
SimpleStatus,
)
from .payments import (
CreateInvoice,
CreatePayment,
DecodePayment,
PayInvoice,
Payment,
PaymentCountField,
PaymentCountStat,
PaymentDailyStats,
PaymentExtra,
PaymentFilters,
PaymentHistoryPoint,
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
)
from .tinyurl import TinyURL
from .users import (
AccessTokenPayload,
Account,
AccountFilters,
AccountOverview,
CreateUser,
LoginUsernamePassword,
LoginUsr,
RegisterUser,
ResetUserPassword,
UpdateBalance,
UpdateSuperuserPassword,
UpdateUser,
UpdateUserPassword,
UpdateUserPubkey,
User,
UserAcls,
UserExtra,
)
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [
# audit
"AuditEntry",
"AuditFilters",
# lnurl
"CreateLnurl",
"CreateLnurlAuth",
"PayLnurlWData",
# misc
"BalanceDelta",
"Callback",
"ConversionData",
"CoreAppExtra",
"DbVersion",
"SimpleStatus",
# payments
"CreateInvoice",
"CreatePayment",
"DecodePayment",
"PayInvoice",
"Payment",
"PaymentCountField",
"PaymentCountStat",
"PaymentDailyStats",
"PaymentsStatusCount",
"PaymentWalletStats",
"PaymentExtra",
"PaymentFilters",
"PaymentHistoryPoint",
"PaymentState",
# tinyurl
"TinyURL",
# users
"AccessTokenPayload",
"Account",
"AccountFilters",
"AccountOverview",
"UserAcls",
"CreateUser",
"RegisterUser",
"LoginUsernamePassword",
"LoginUsr",
"ResetUserPassword",
"UpdateBalance",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
"UpdateUserPubkey",
"User",
"UserExtra",
# wallets
"BaseWallet",
"CreateWallet",
"KeyType",
"Wallet",
"WalletTypeInfo",
# webpush
"CreateWebPushSubscription",
"WebPushSubscription",
]
-61
View File
@@ -1,61 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pydantic import BaseModel, Field
from lnbits.db import FilterModel
from lnbits.settings import settings
class AuditEntry(BaseModel):
component: str | None = None
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_type: str | None = None
request_method: str | None = None
request_details: str | None = None
response_code: str | None = None
duration: float
delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days)
class AuditFilters(FilterModel):
__search_fields__ = [
"ip_address",
"user_id",
"path",
"request_method",
"response_code",
"component",
]
__sort_fields__ = [
"created_at",
"duration",
]
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_method: str | None = None
response_code: str | None = None
component: str | None = None
class AuditCountStat(BaseModel):
field: str = ""
total: float = 0
class AuditStats(BaseModel):
request_method: list[AuditCountStat] = []
response_code: list[AuditCountStat] = []
component: list[AuditCountStat] = []
long_duration: list[AuditCountStat] = []
-736
View File
@@ -1,736 +0,0 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import zipfile
from pathlib import Path
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel
from lnbits.helpers import (
download_url,
file_hash,
is_lnbits_version_ok,
version_parse,
)
from lnbits.settings import settings
class ExplicitRelease(BaseModel):
id: str
name: str
version: str
archive: str
hash: str
dependencies: list[str] = []
repo: str | None
icon: str | None
short_description: str | None
min_lnbits_version: str | None
max_lnbits_version: str | None
html_url: str | None # todo: release_url
warning: str | None
info_notification: str | None
critical_notification: str | None
details_link: str | None
pay_link: str | None
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
class GitHubRelease(BaseModel):
id: str
organisation: str
repository: str
class Manifest(BaseModel):
featured: list[str] = []
extensions: list[ExplicitRelease] = []
repos: list[GitHubRelease] = []
class GitHubRepoRelease(BaseModel):
name: str
tag_name: str
zipball_url: str
html_url: str
def details_link(self, source_repo: str) -> str:
return f"https://raw.githubusercontent.com/{source_repo}/{self.tag_name}/config.json"
class GitHubRepo(BaseModel):
stargazers_count: str
html_url: str
default_branch: str
class ExtensionConfig(BaseModel):
name: str
short_description: str
tile: str = ""
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> ExtensionConfig | None:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config)
class ReleasePaymentInfo(BaseModel):
amount: int | None = None
pay_link: str | None = None
payment_hash: str | None = None
payment_request: str | None = None
class PayToEnableInfo(BaseModel):
amount: int = 0
required: bool = False
wallet: str | None = None
class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: UserExtensionInfo | None = None
@property
def is_paid(self) -> bool:
if not self.extra:
return False
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> UserExtension:
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
if "_extra" in data
else None
)
return ext
class Extension(BaseModel):
code: str
is_valid: bool
name: str | None = None
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
@property
def module_name(self) -> str:
if self.is_upgrade_extension:
return f"{self.code}-{self.upgrade_hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.code}"
return self.code
@property
def is_upgrade_extension(self) -> bool:
return self.upgrade_hash != ""
@classmethod
def from_installable_ext(cls, ext_info: InstallableExtension) -> Extension:
return Extension(
code=ext_info.id,
is_valid=True,
name=ext_info.name,
short_description=ext_info.short_description,
tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
)
class ExtensionRelease(BaseModel):
name: str
version: str
archive: str
source_repo: str
is_github_release: bool = False
hash: str | None = None
min_lnbits_version: str | None = None
max_lnbits_version: str | None = None
is_version_compatible: bool | None = True
html_url: str | None = None
description: str | None = None
warning: str | None = None
repo: str | None = None
icon: str | None = None
details_link: str | None = None
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
@property
def archive_url(self) -> str:
if not self.pay_link:
return self.archive
return (
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
)
async def check_payment_requirements(self):
if not self.pay_link:
return
payment_info = await self.fetch_release_payment_info()
self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: int | None = None
) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
return ReleasePaymentInfo(**resp.json())
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_github_release(
cls, source_repo: str, r: GitHubRepoRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=r.name,
description=r.name,
version=r.tag_name,
archive=r.zipball_url,
source_repo=source_repo,
is_github_release=True,
details_link=r.details_link(source_repo),
repo=f"https://github.com/{source_repo}",
html_url=r.html_url,
)
@classmethod
def from_explicit_release(
cls, source_repo: str, e: ExplicitRelease
) -> ExtensionRelease:
return ExtensionRelease(
name=e.name,
version=e.version,
archive=e.archive,
hash=e.hash,
source_repo=source_repo,
description=e.short_description,
min_lnbits_version=e.min_lnbits_version,
max_lnbits_version=e.max_lnbits_version,
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
details_link=e.details_link,
pay_link=e.pay_link,
repo=e.repo,
icon=e.icon,
)
@classmethod
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
except Exception as e:
logger.warning(e)
return []
@classmethod
async def fetch_github_releases(
cls, org: str, repo: str
) -> list[GitHubRepoRelease]:
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(details_link)
resp.raise_for_status()
data = resp.json()
if "description_md" in data:
resp = await client.get(data["description_md"])
if not resp.is_error:
data["description_md"] = resp.text
return data
except Exception as e:
logger.warning(e)
return None
class ExtensionMeta(BaseModel):
installed_release: ExtensionRelease | None = None
latest_release: ExtensionRelease | None = None
pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: str | None = None
featured: bool = False
class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: bool | None = False
short_description: str | None = None
icon: str | None = None
stars: int = 0
meta: ExtensionMeta | None = None
@property
def hash(self) -> str:
if self.meta and self.meta.installed_release:
if self.meta.installed_release.hash:
return self.meta.installed_release.hash
m = hashlib.sha256()
m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest()
return "not-installed"
@property
def zip_path(self) -> Path:
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
return Path(extensions_data_dir, f"{self.id}.zip")
@property
def ext_dir(self) -> Path:
return Path(settings.lnbits_extensions_path, "extensions", self.id)
@property
def ext_upgrade_dir(self) -> Path:
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
@property
def module_name(self) -> str:
if self.ext_upgrade_dir.is_dir():
return f"{self.id}-{self.hash}"
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.id}"
return self.id
@property
def has_installed_version(self) -> bool:
if not self.ext_dir.is_dir():
return False
return Path(self.ext_dir, "config.json").is_file()
@property
def installed_version(self) -> str:
if self.meta and self.meta.installed_release:
return self.meta.installed_release.version
return ""
@property
def requires_payment(self) -> bool:
if not self.meta or not self.meta.pay_to_enable:
return False
return self.meta.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
assert (
self.meta and self.meta.installed_release
), "installed_release is none."
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.meta.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
except Exception as exc:
logger.warning(exc)
raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file)
if (
self.meta
and self.meta.installed_release.hash
and self.meta.installed_release.hash != archive_hash
):
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
raise AssertionError("File hash missmatch. Will not install.")
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0]
shutil.rmtree(self.ext_upgrade_dir, True)
shutil.copytree(
Path(tmp_dir, generated_dir_name),
Path(self.ext_upgrade_dir),
)
shutil.rmtree(tmp_dir, True)
# Pre-packed extensions can be upgraded
# Mark the extension as installed so we know it is not the pre-packed version
with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
config_json = json.load(json_file)
self.name = config_json.get("name")
self.short_description = config_json.get("short_description")
if (
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile")
):
self.icon = icon_to_github_url(
self.meta.installed_release.source_repo, config_json.get("tile")
)
shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
if self.zip_path.is_file():
os.remove(self.zip_path)
# remove module from extensions
shutil.rmtree(self.ext_dir, True)
shutil.rmtree(self.ext_upgrade_dir, True)
def check_latest_version(self, release: ExtensionRelease | None):
if not release:
return
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return
if version_parse(self.meta.latest_release.version) < version_parse(
release.version
):
self.meta.latest_release = 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 _restore_payment_info(self):
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
or not self.meta.installed_release.payment_hash
):
return
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
if payment_info:
self.meta.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
):
return
payment_info = ReleasePaymentInfo(
amount=self.meta.installed_release.cost_sats,
pay_link=self.meta.installed_release.pay_link,
payment_hash=self.meta.installed_release.payment_hash,
)
self.meta.payments = [
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
]
self.meta.payments.append(payment_info)
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> InstallableExtension | None:
try:
repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
return InstallableExtension(
id=github_release.id,
name=config.name,
version=latest_release.tag_name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
source_repo,
config.tile,
),
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
return InstallableExtension(
id=e.id,
name=e.name,
version=e.version,
short_description=e.short_description,
icon=e.icon,
meta=meta,
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
version = config_json.get("version", "0.0")
return InstallableExtension(
id=ext_id,
name=config_json.get("name", ext_id),
active=True,
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
version=version,
archive=f"{conf_path}",
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"),
)
),
)
except Exception as e:
logger.warning(e)
return None
@classmethod
async def get_installable_extensions(
cls,
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
ext = await InstallableExtension.from_github_release(r)
if not ext:
continue
existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release)
continue
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
for e in manifest.extensions:
release = ExtensionRelease.from_explicit_release(url, e)
existing_ext = next(
(ee for ee in extension_list if ee.id == e.id), None
)
if existing_ext:
existing_ext.check_latest_version(release)
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release)
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'")
settings.lnbits_all_extensions_ids = {e.id for e in extension_list}
return extension_list
@classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
if r.id != ext_id:
continue
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
extension_releases += repo_releases
for e in manifest.extensions:
if e.id != ext_id:
continue
explicit_release = ExtensionRelease.from_explicit_release(url, e)
await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)
except Exception as e:
logger.warning(f"Manifest {url} failed with '{e!s}'")
return extension_releases
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> ExtensionRelease | None:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
selected_release = [
r
for r in all_releases
if r.archive == archive
and r.source_repo == source_repo
and r.version == version
]
return selected_release[0] if len(selected_release) != 0 else None
@classmethod
async def fetch_github_repo_info(
cls, org: str, repository: str
) -> tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo)
lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest"
)
error_msg = "Cannot fetch extension releases"
latest_release: Any = await github_api_get(lates_release_url, error_msg)
config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
error_msg = "Cannot fetch config for extension"
config = await github_api_get(config_url, error_msg)
return (
github_repo,
GitHubRepoRelease.parse_obj(latest_release),
ExtensionConfig.parse_obj(config),
)
@classmethod
async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest)
class CreateExtension(BaseModel):
ext_id: str
archive: str
source_repo: str
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
class ExtensionDetailsRequest(BaseModel):
ext_id: str
source_repo: str
version: str
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
async with httpx.AsyncClient(headers=headers) as client:
resp = await client.get(url)
if resp.status_code != 200:
logger.warning(f"{error_msg} ({url}): {resp.text}")
resp.raise_for_status()
return resp.json()
def icon_to_github_url(source_repo: str, path: str | None) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}"
-20
View File
@@ -1,20 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class CreateLnurl(BaseModel):
description_hash: str
callback: str
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateLnurlAuth(BaseModel):
callback: str
class PayLnurlWData(BaseModel):
lnurl_w: str
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from typing import Callable
from pydantic import BaseModel
def _do_nothing(*_):
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
class ConversionData(BaseModel):
from_: str = "sat"
amount: float
to: str = "usd"
class Callback(BaseModel):
callback: str
class BalanceDelta(BaseModel):
lnbits_balance_sats: int
node_balance_sats: int
@property
def delta_sats(self) -> int:
return int(self.lnbits_balance_sats - self.node_balance_sats)
class SimpleStatus(BaseModel):
success: bool
message: str
class SimpleItem(BaseModel):
id: str
name: str
class DbVersion(BaseModel):
db: str
version: int
-64
View File
@@ -1,64 +0,0 @@
from enum import Enum
from pydantic import BaseModel
class NotificationType(Enum):
server_status = "server_status"
settings_update = "settings_update"
balance_update = "balance_update"
watchdog_check = "watchdog_check"
balance_delta = "balance_delta"
server_start_stop = "server_start_stop"
incoming_payment = "incoming_payment"
outgoing_payment = "outgoing_payment"
text_message = "text_message"
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
NOTIFICATION_TEMPLATES = {
"text_message": "{message}",
"server_status": """*SERVER STATUS*
*Up time*: `{up_time}`.
*Accounts*: `{accounts_count}`.
*Wallets*: `{wallets_count}`.
*In/Out payments*: `{in_payments_count}`/`{out_payments_count}`.
*Pending payments*: `{pending_payments_count}`.
*Failed payments*: `{failed_payments_count}`.
*LNbits balance*: `{lnbits_balance_sats}` sats.""",
"server_start_stop": """*SERVER*
{message}
*Time*: `{up_time}` seconds.
""",
"settings_update": """*SETTINGS UPDATED*
User: `{username}`.
""",
"balance_update": """*WALLET CREDIT/DEBIT*
Wallet `{wallet_name}` balance updated with `{amount}` sats.
*Current balance*: `{balance}` sats.
*Wallet ID*: `{wallet_id}`
""",
"watchdog_check": """*WATCHDOG BALANCE CHECK*
*Delta*: `{delta_sats}` sats.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.
*Switching to Void Wallet*: `{switch_to_void_wallet}`.
""",
"balance_delta": """*BALANCE DELTA CHANGED*
*New delta*: `{delta_sats}` sats.
*Old delta*: `{old_delta_sats}` sats.
*LNbits balance*: `{lnbits_balance_sats}` sats.
*Node balance*: `{node_balance_sats}` sats.
""",
"outgoing_payment": """*PAYMENT SENT*
*Amount*: {fiat_value_fmt}`{amount_sats}`*sats*.
*Wallet*: `{wallet_name}` ({wallet_id}).""",
"incoming_payment": """*PAYMENT RECEIVED*
*Amount*: {fiat_value_fmt}`{amount_sats}`*sats*.
*Wallet*: `{wallet_name}` ({wallet_id}).
""",
}
-222
View File
@@ -1,222 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal
from fastapi import Query
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus,
PaymentSuccessStatus,
)
class PaymentState(str, Enum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
def __str__(self) -> str:
return self.value
class PaymentExtra(BaseModel):
comment: str | None = None
success_action: str | None = None
lnurl_response: str | None = None
class PayInvoice(BaseModel):
payment_request: str
description: str | None = None
max_sat: int | None = None
extra: dict | None = {}
class CreatePayment(BaseModel):
wallet_id: str
payment_hash: str
bolt11: str
amount_msat: int
memo: str
extra: dict | None = {}
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
class Payment(BaseModel):
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
bolt11: str
status: str = PaymentState.PENDING
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: int | None = None
preimage: str | None = None
tag: str | None = None
extension: str | None = None
time: 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))
extra: dict = {}
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
return self.status == PaymentState.SUCCESS.value
@property
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
@property
def is_expired(self) -> bool:
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
return self.checking_id.startswith("internal_")
async def check_status(self) -> PaymentStatus:
if self.is_internal:
if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
class PaymentFilters(FilterModel):
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
status: str | None
tag: str | None
checking_id: str | None
amount: int
fee: int
memo: str | None
time: datetime
preimage: str | None
payment_hash: str | None
wallet_id: str | None
class PaymentDataPoint(BaseModel):
date: datetime
count: int
max_amount: int
min_amount: int
average_amount: int
total_amount: int
max_fee: int
min_fee: int
average_fee: int
total_fee: int
PaymentCountField = Literal["status", "tag", "extension", "wallet_id"]
class PaymentCountStat(BaseModel):
field: str = ""
total: float = 0
class PaymentWalletStats(BaseModel):
wallet_id: str = ""
wallet_name: str = ""
user_id: str = ""
payments_count: int
balance: float = 0
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
balance_in: float | None = 0
balance_out: float | None = 0
payments_count: int = 0
count_in: int | None = 0
count_out: int | None = 0
fee: float = 0
class PaymentHistoryPoint(BaseModel):
date: datetime
income: int
spending: int
balance: int
class DecodePayment(BaseModel):
data: str
filter_fields: list[str] | None = []
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: str | None = None
description_hash: str | None = None
unhashed_description: str | None = None
expiry: int | None = None
extra: dict | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_callback: str | None = None
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
return v
class PaymentsStatusCount(BaseModel):
incoming: int = 0
outgoing: int = 0
failed: int = 0
pending: int = 0
-9
View File
@@ -1,9 +0,0 @@
from pydantic import BaseModel
class TinyURL(BaseModel):
id: str
url: str
endless: bool
wallet: str
time: float
-289
View File
@@ -1,289 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
from lnbits.db import FilterModel
from lnbits.helpers import is_valid_email_address, is_valid_pubkey, is_valid_username
from lnbits.settings import settings
from .wallets import Wallet
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
last_name: str | None = None
display_name: str | None = None
picture: str | None = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: str | None = "lnbits" # auth provider
class EndpointAccess(BaseModel):
path: str
name: str
read: bool = False
write: bool = False
def supports_method(self, method: str) -> bool:
# all http methods
if method in ["GET", "OPTIONS", "HEAD"]:
return self.read
if method in ["POST", "PUT", "PATCH", "DELETE"]:
return self.write
return False
class AccessControlList(BaseModel):
id: str
name: str
endpoints: list[EndpointAccess] = []
token_id_list: list[SimpleItem] = []
def get_endpoint(self, path: str) -> EndpointAccess | None:
for e in self.endpoints:
if e.path == path:
return e
return None
def get_token_by_id(self, token_id: str) -> SimpleItem | None:
for t in self.token_id_list:
if t.id == token_id:
return t
return None
def delete_token_by_id(self, token_id: str):
self.token_id_list = [t for t in self.token_id_list if t.id != token_id]
class UserAcls(BaseModel):
id: str
access_control_list: list[AccessControlList] = []
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def get_acl_by_id(self, acl_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.id == acl_id:
return acl
return None
def delete_acl_by_id(self, acl_id: str):
self.access_control_list = [
acl for acl in self.access_control_list if acl.id != acl_id
]
def get_acl_by_token_id(self, token_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.get_token_by_id(token_id):
return acl
return None
class Account(BaseModel):
id: str
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True)
is_admin: bool = Field(default=False, no_database=True)
def __init__(self, **data):
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id)
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
def validate_fields(self):
if self.username and not is_valid_username(self.username):
raise ValueError("Invalid username.")
if self.email and not is_valid_email_address(self.email):
raise ValueError("Invalid email.")
if self.pubkey and not is_valid_pubkey(self.pubkey):
raise ValueError("Invalid pubkey.")
user_uuid4 = UUID(hex=self.id, version=4)
if user_uuid4.hex != self.id:
raise ValueError("User ID is not valid UUID4 hex string.")
class AccountOverview(Account):
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
last_payment: datetime | None = None
class AccountFilters(FilterModel):
__search_fields__ = ["user", "email", "username", "pubkey", "wallet_id"]
__sort_fields__ = [
"balance_msat",
"email",
"username",
"transaction_count",
"wallet_count",
"last_payment",
]
email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None
wallet_id: str | None = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: str | None = None
username: str | None = None
pubkey: str | None = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
super_user: bool = False
has_password: bool = False
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Wallet | None:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@classmethod
def is_extension_for_user(cls, ext: str, user: str) -> bool:
if ext not in settings.lnbits_admin_extensions:
return True
if user == settings.super_user:
return True
if user in settings.lnbits_admin_users:
return True
return False
class RegisterUser(BaseModel):
email: str | None = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel):
id: str | None = Query(default=None)
email: str | None = Query(default=None)
username: str | None = Query(default=None, min_length=2, max_length=20)
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)
pubkey: str = Query(default=None, max_length=64)
extensions: list[str] | None = None
extra: UserExtra | None = None
class UpdateUser(BaseModel):
user_id: str
email: str | None = Query(default=None)
username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: str | None = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
class UpdateUserPubkey(BaseModel):
user_id: str
pubkey: str = Query(default=..., max_length=64)
class ResetUserPassword(BaseModel):
reset_key: str
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class LoginUsr(BaseModel):
usr: str
class LoginUsernamePassword(BaseModel):
username: str
password: str
class AccessTokenPayload(BaseModel):
sub: str
usr: str | None = None
email: str | None = None
auth_time: int | None = 0
api_token_id: str | None = None
class UpdateBalance(BaseModel):
id: str
amount: int
class ApiTokenRequest(BaseModel):
acl_id: str
token_name: str
password: str
expiration_time_minutes: int
class ApiTokenResponse(BaseModel):
id: str
api_token: str
class UpdateAccessControlList(AccessControlList):
password: str
class DeleteAccessControlList(BaseModel):
id: str
password: str
class DeleteTokenRequest(BaseModel):
id: str
acl_id: str
password: str
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
class BaseWallet(BaseModel):
id: str
name: str
adminkey: str
inkey: str
balance_msat: int
class WalletExtra(BaseModel):
icon: str = "flash_on"
color: str = "primary"
class Wallet(BaseModel):
id: str
user: str
name: str
adminkey: str
inkey: str
deleted: bool = False
created_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
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
@property
def balance(self) -> int:
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
return self.balance_msat - settings.fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: str | None = None
class KeyType(Enum):
admin = 0
invoice = 1
invalid = 2
# backwards compatibility
def __eq__(self, other):
return self.value == other
@dataclass
class WalletTypeInfo:
key_type: KeyType
wallet: Wallet
-15
View File
@@ -1,15 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
class CreateWebPushSubscription(BaseModel):
subscription: str
class WebPushSubscription(BaseModel):
endpoint: str
user: str
data: str
host: str
timestamp: datetime
+786
View File
@@ -0,0 +1,786 @@
import asyncio
import datetime
import json
import time
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
import httpx
from bolt11 import Bolt11
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import (
EditableSettings,
SuperSettings,
readonly_variables,
send_admin_user_to_saas,
settings,
)
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import FAKE_WALLET, get_wallet_class, set_wallet_class
from lnbits.wallets.base import PaymentResponse, PaymentStatus
from .crud import (
check_internal,
check_internal_pending,
create_account,
create_admin_settings,
create_payment,
create_wallet,
delete_wallet_payment,
get_account,
get_payments,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_wallet,
get_wallet_payment,
update_admin_settings,
update_payment_details,
update_payment_status,
update_super_user,
)
from .helpers import to_valid_user_id
from .models import Payment, UserConfig, Wallet
class PaymentFailure(Exception):
pass
class InvoiceFailure(Exception):
pass
async def calculate_fiat_amounts(
amount: float,
wallet_id: str,
currency: Optional[str] = None,
extra: Optional[Dict] = None,
conn: Optional[Connection] = None,
) -> Tuple[int, Optional[Dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency:
extra["fiat_currency"] = currency
extra["fiat_amount"] = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount
else:
amount_sat = int(amount)
if wallet_currency:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {}
extra["wallet_fiat_currency"] = wallet_currency
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
)
return amount_sat, extra
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[Dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Tuple[str, str]:
if not amount > 0:
raise InvoiceFailure("Amountless invoices not supported.")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceFailure(f"Could not fetch wallet '{wallet_id}'.")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
wallet = FAKE_WALLET if internal else get_wallet_class()
amount_sat, extra = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceFailure(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats."
)
ok, checking_id, payment_request, error_message = await wallet.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 ok or not payment_request or not checking_id:
raise InvoiceFailure(error_message or "unexpected backend error.")
invoice = bolt11_decode(payment_request)
amount_msat = 1000 * amount_sat
await create_payment(
wallet_id=wallet_id,
checking_id=checking_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=amount_msat,
expiry=get_bolt11_expiry(invoice),
memo=memo,
extra=extra,
webhook=webhook,
conn=conn,
)
return invoice.payment_hash, payment_request
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
conn: Optional[Connection] = None,
) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve
fee. We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend. If the payment is
successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process
will regularly check for the payment.
"""
invoice = bolt11_decode(payment_request)
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
async with db.reuse_conn(conn) if conn else db.connect() as conn:
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
_, extra = await calculate_fiat_amounts(
invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
)
# put all parameters that don't change here
class PaymentKwargs(TypedDict):
wallet_id: str
payment_request: str
payment_hash: str
amount: int
memo: str
expiry: Optional[datetime.datetime]
extra: Optional[Dict]
payment_kwargs: PaymentKwargs = PaymentKwargs(
wallet_id=wallet_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat,
expiry=get_bolt11_expiry(invoice),
memo=description or invoice.description or "",
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
raise PaymentFailure("Internal invoice already paid.")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=True
)
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
internal_invoice.amount != invoice.amount_msat
or internal_invoice.bolt11 != payment_request.lower()
):
raise PaymentFailure("Invalid invoice.")
logger.debug(f"creating temporary internal payment with id {internal_id}")
# create a new payment from this wallet
new_payment = await create_payment(
checking_id=internal_id,
fee=0 + abs(fee_reserve_total_msat),
pending=False,
conn=conn,
**payment_kwargs,
)
else:
fee_reserve_total_msat = fee_reserve_total(
invoice.amount_msat, internal=False
)
logger.debug(f"creating temporary payment with id {temp_id}")
# create a temporary payment here so we can check if
# the balance is enough in the next step
try:
new_payment = await create_payment(
checking_id=temp_id,
fee=-abs(fee_reserve_total_msat),
conn=conn,
**payment_kwargs,
)
except Exception as e:
logger.error(f"could not create temporary payment: {e}")
# happens if the same wallet tries to pay an invoice twice
raise PaymentFailure("Could not make payment.")
# do the balance check
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if (
not internal_checking_id
and wallet.balance_msat > -fee_reserve_total_msat
):
raise PaymentFailure(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees."
)
raise PermissionError("Insufficient balance.")
if internal_checking_id:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id, pending=False, conn=conn
)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
WALLET = get_wallet_class()
payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.checking_id and payment.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
pending=payment.ok is not True,
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
# payment failed
logger.warning("backend sent payment failure")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_wallet_payment(temp_id, wallet_id, conn=conn)
raise PaymentFailure(
f"Payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message."
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {temp_id}"
)
# credit service fee wallet
if settings.lnbits_service_fee_wallet and service_fee_msat:
new_payment = await create_payment(
wallet_id=settings.lnbits_service_fee_wallet,
fee=0,
amount=abs(service_fee_msat),
memo="Service fee",
checking_id="service_fee" + temp_id,
payment_request=payment_request,
payment_hash=invoice.payment_hash,
pending=False,
)
return invoice.payment_hash
async def check_wallet_limits(wallet_id, conn, amount_msat):
await check_time_limit_between_transactions(conn, wallet_id)
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
async def check_time_limit_between_transactions(conn, wallet_id):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise ValueError(
f"The time limit of {limit} seconds between payments has been reached."
)
async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
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:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
conn: Optional[Connection] = None,
) -> 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"
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
)
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentStatus(None)
if not payment.pending:
# note: before, we still checked the status of the payment again
return PaymentStatus(True, fee_msat=payment.fee)
status: PaymentStatus = await payment.check_status()
return status
# WARN: this same value must be used for balance check and passed to
# WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
if internal:
return 0
reserve_min = settings.lnbits_reserve_fee_min
reserve_percent = settings.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
def service_fee(amount_msat: int, internal: bool = False) -> int:
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocketUpdater(
wallet.id,
json.dumps(
{
"wallet_balance": wallet.balance,
"payment": payment.dict(),
}
),
)
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(checking_id=checking_id, pending=False, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def update_cached_settings(sets_dict: dict):
for key, value in sets_dict.items():
if key not in readonly_variables:
try:
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}, value: {value}")
if "super_user" in sets_dict:
setattr(settings, "super_user", sets_dict["super_user"])
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
)
if not account.wallets or len(account.wallets) == 0:
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocketManager = WebsocketConnectionManager()
async def websocketUpdater(item_id, data):
return await websocketManager.send_data(f"{data}", item_id)
async def switch_to_voidwallet() -> None:
WALLET = get_wallet_class()
if WALLET.__class__.__name__ == "VoidWallet":
return
set_wallet_class("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> Tuple[int, int, int]:
WALLET = get_wallet_class()
total_balance = await get_total_balance()
error_message, node_balance = await WALLET.status()
if error_message:
raise Exception(error_message)
return node_balance - total_balance, node_balance, total_balance
def get_bolt11_expiry(invoice: Bolt11) -> datetime.datetime:
if invoice.expiry:
return datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
else:
# assume maximum bolt11 expiry of 31 days to be on the safe side
return datetime.datetime.now() + datetime.timedelta(days=31)
-67
View File
@@ -1,67 +0,0 @@
from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
check_transaction_status,
check_wallet_limits,
create_invoice,
fee_reserve,
fee_reserve_total,
pay_invoice,
service_fee,
update_pending_payments,
update_wallet_balance,
)
from .settings import (
check_webpush_settings,
update_cached_settings,
)
from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
from .websockets import websocket_manager, websocket_updater
__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",
"check_transaction_status",
"check_wallet_limits",
"create_invoice",
"fee_reserve",
"fee_reserve_total",
"pay_invoice",
"service_fee",
"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",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
# websockets
"websocket_manager",
"websocket_updater",
]
-179
View File
@@ -1,179 +0,0 @@
import asyncio
import importlib
from typing import Optional
from loguru import logger
from lnbits.core import core_app_extra
from lnbits.core.crud import (
create_installed_extension,
delete_installed_extension,
get_db_version,
get_installed_extension,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.helpers import migrate_extension_database
from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
if ext_info.meta.installed_release:
assert (
ext_info.meta.installed_release.is_version_compatible
), "Incompatible extension version"
installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
await ext_info.download_archive()
ext_info.extract_archive()
db_version = await get_db_version(ext_info.id)
await migrate_extension_database(ext_info, db_version)
# if the extensions does not exist in the installed extensions table, create it
# if it does exist, it will be activated later in the code
if not installed_ext:
await create_installed_extension(ext_info)
else:
await update_installed_extension(ext_info)
extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
await start_extension_background_work(ext_info.id)
return extension
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
settings.deactivate_extension_paths(ext_id)
extension = await get_installed_extension(ext_id)
if extension:
extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id)
async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
async def stop_extension_background_work(ext_id: str) -> bool:
"""
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks.
"""
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
try:
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
old_module = importlib.import_module(ext.module_name)
stop_fn_name = f"{ext_id}_stop"
assert hasattr(
old_module, stop_fn_name
), f"No stop function found for '{ext.module_name}'."
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
if asyncio.iscoroutinefunction(stop_fn):
await stop_fn()
else:
stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex:
logger.warning(f"Failed to stop background work for '{ext.module_name}'.")
logger.warning(ex)
return False
return True
async def start_extension_background_work(ext_id: str) -> bool:
"""
Start background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case.
"""
upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
try:
logger.info(f"Starting background work for extension '{ext.module_name}'.")
new_module = importlib.import_module(ext.module_name)
start_fn_name = f"{ext_id}_start"
# start function is optional, return False if not found
if not hasattr(new_module, start_fn_name):
return False
start_fn = getattr(new_module, start_fn_name)
if start_fn:
if asyncio.iscoroutinefunction(start_fn):
await start_fn()
else:
start_fn()
logger.info(f"Started background work for extension '{ext.module_name}'.")
return True
except Exception as ex:
logger.warning(f"Failed to start background work for '{ext.module_name}'.")
logger.warning(ex)
return False
async def get_valid_extensions(
include_deactivated: Optional[bool] = True,
) -> list[Extension]:
installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated:
return valid_extensions
if settings.lnbits_extensions_deactivate_all:
return []
return [
e
for e in valid_extensions
if e.code not in settings.lnbits_deactivated_extensions
]
async def get_valid_extension(
ext_id: str, include_deactivated: Optional[bool] = True
) -> Optional[Extension]:
ext = await get_installed_extension(ext_id)
if not ext:
return None
if include_deactivated:
return Extension.from_installable_ext(ext)
if settings.lnbits_extensions_deactivate_all:
return None
return Extension.from_installable_ext(ext)
-83
View File
@@ -1,83 +0,0 @@
from loguru import logger
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
from ..crud import get_total_balance
from ..models import BalanceDelta
async def switch_to_voidwallet() -> None:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
set_funding_source("VoidWallet")
settings.lnbits_backend_wallet_class = "VoidWallet"
async def get_balance_delta() -> BalanceDelta:
funding_source = get_funding_source()
status = await funding_source.status()
lnbits_balance = await get_total_balance()
return BalanceDelta(
lnbits_balance_sats=int(lnbits_balance) // 1000,
node_balance_sats=status.balance_msat // 1000,
)
async def check_server_balance_against_node():
"""
Watchdog will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached.
"""
if (
not settings.lnbits_watchdog_switch_to_voidwallet
and not settings.lnbits_notification_watchdog
):
return
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
return
status = await get_balance_delta()
if status.delta_sats < settings.lnbits_watchdog_delta:
return
use_voidwallet = settings.lnbits_watchdog_switch_to_voidwallet
logger.warning(
f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}."
)
enqueue_notification(
NotificationType.watchdog_check,
{
"delta_sats": status.delta_sats,
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
"switch_to_void_wallet": use_voidwallet,
},
)
if use_voidwallet:
logger.error(f"Switching to VoidWallet. Delta: {status.delta_sats} sats.")
await switch_to_voidwallet()
async def check_balance_delta_changed():
status = await get_balance_delta()
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
return
if status.delta_sats != settings.latest_balance_delta_sats:
enqueue_notification(
NotificationType.balance_delta,
{
"delta_sats": status.delta_sats,
"old_delta_sats": settings.latest_balance_delta_sats,
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
},
)
settings.latest_balance_delta_sats = status.delta_sats
-158
View File
@@ -1,158 +0,0 @@
import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
import httpx
from fastapi import Depends
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
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 .payments import create_invoice
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
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()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
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
)
-73
View File
@@ -1,73 +0,0 @@
import asyncio
from typing import Tuple
import httpx
from loguru import logger
from pynostr.encrypted_dm import EncryptedDirectMessage
from websocket import WebSocket, create_connection
from lnbits.core.helpers import is_valid_url
from lnbits.utils.nostr import (
validate_identifier,
validate_pub_key,
)
async def send_nostr_dm(
from_private_key_hex: str,
to_pubkey_hex: str,
message: str,
relays: list[str],
) -> dict:
dm = EncryptedDirectMessage()
dm.encrypt(
private_key_hex=from_private_key_hex,
recipient_pubkey=to_pubkey_hex,
cleartext_content=message,
)
dm_event = dm.to_event()
dm_event.sign(private_key_hex=from_private_key_hex)
notification = dm_event.to_message()
ws_connections: list[WebSocket] = []
for relay in relays:
try:
ws = create_connection(relay, timeout=2)
ws.send(notification)
ws_connections.append(ws)
except Exception as e:
logger.warning(f"Error sending notification to relay {relay}: {e}")
await asyncio.sleep(1)
for ws in ws_connections:
try:
ws.close()
except Exception as e:
logger.debug(f"Failed to close websocket connection: {e}")
return dm_event.to_dict()
async def fetch_nip5_details(identifier: str) -> Tuple[str, list[str]]:
identifier, domain = identifier.split("@")
if not identifier or not domain:
raise ValueError("Invalid NIP5 identifier")
if not is_valid_url(f"https://{domain}"):
raise ValueError("Invalid NIP5 domain")
validate_identifier(identifier)
url = f"https://{domain}/.well-known/nostr.json?name={identifier}"
async with httpx.AsyncClient() as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
if "names" not in data or identifier not in data["names"]:
raise ValueError("NIP5 not name found")
pubkey = data["names"][identifier]
validate_pub_key(pubkey)
relays = data["relays"].get(pubkey, []) if "relays" in data else []
return pubkey, relays
-314
View File
@@ -1,314 +0,0 @@
import asyncio
import json
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
from typing import Optional, Tuple
import httpx
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_webpush_subscriptions,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
NotificationMessage,
NotificationType,
)
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue = asyncio.Queue()
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
if not is_message_type_enabled(message_type):
return
try:
notifications_queue.put_nowait(
NotificationMessage(message_type=message_type, values=values)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification():
notification_message: NotificationMessage = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message)
await send_notification(text, message_type)
async def send_notification(
message: str,
message_type: Optional[str] = None,
) -> None:
try:
if settings.lnbits_telegram_notifications_enabled:
await send_telegram_notification(message)
logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}")
try:
if settings.lnbits_nostr_notifications_enabled:
await send_nostr_notification(message)
logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}")
try:
if settings.lnbits_email_notifications_enabled:
await send_email_notification(message)
logger.debug(f"Sent email notification: {message_type}")
except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notification(message: str) -> dict:
for i in settings.lnbits_nostr_notifications_identifiers:
try:
identifier = await fetch_nip5_details(i)
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:
logger.warning(f"Error notifying identifier {i}: {e}")
return {"status": "ok"}
async def send_telegram_notification(message: str) -> dict:
return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token,
settings.lnbits_telegram_notifications_chat_id,
message,
)
async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {"chat_id": chat_id, "text": message, "parse_mode": "markdown"}
async with httpx.AsyncClient() as client:
response = await client.post(url, data=payload)
response.raise_for_status()
return response.json()
async def send_email_notification(message: str) -> dict:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
"LNbits Notification",
message,
)
return {"status": "ok"}
async def send_email(
server: str,
port: int,
password: str,
from_email: str,
to_emails: list,
subject: str,
message: str,
):
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
try:
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(from_email, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
logger.debug(f"Emails sent successfully to: {', '.join(to_emails)}")
except Exception as e:
logger.debug(f"Failed to send email: {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_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):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send webhook to {payment.webhook}")
async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending websocket payment notification", e)
try:
send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending chat payment notification", e)
try:
await send_payment_push_notification(wallet, payment)
except Exception as e:
logger.error("Error sending push payment notification", e)
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# 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
payment_notification = json.dumps(
{
"wallet_balance": wallet.balance,
# use pydantic json serialization to get the correct datetime format
"payment": json.loads(payment.json()),
},
)
await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
)
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
"wallet_name": wallet.name,
"amount_sats": amount_sats,
"fiat_value_fmt": "",
}
if payment.extra.get("wallet_fiat_currency", None):
amount_fiat = payment.extra.get("wallet_fiat_amount", None)
currency = payment.extra.get("wallet_fiat_currency", None)
values["fiat_value_fmt"] = f"`{amount_fiat}`*{currency}* / "
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_notification(NotificationType.outgoing_payment, values)
else:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
await send_push_notification(subscription, title, body, url)
async def send_push_notification(subscription, title, body, url=""):
vapid = Vapid()
try:
logger.debug("sending push notification")
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
(
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8"))
if settings.lnbits_webpush_privkey
else None
),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
-697
View File
@@ -1,697 +0,0 @@
import asyncio
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode
from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import settings
from lnbits.tasks import create_task
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.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import (
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
from ..crud import (
check_internal,
create_payment,
get_payments,
get_standalone_payment,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_payment,
)
from ..models import (
CreatePayment,
Payment,
PaymentState,
Wallet,
)
from .notifications import send_payment_notification
async def pay_invoice(
*,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
tag: str = "",
conn: Optional[Connection] = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
if await is_internal_status_success(invoice.payment_hash, new_conn):
raise PaymentError("Internal invoice already paid.", status="failed")
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=-amount_msat,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
)
payment = await _pay_invoice(wallet, create_payment_model, conn)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, new_conn)
return payment
async def create_invoice(
*,
wallet_id: str,
amount: float,
currency: Optional[str] = "sat",
memo: str,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
expiry: Optional[int] = None,
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
user_wallet = await get_wallet(wallet_id, conn=conn)
if not user_wallet:
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
invoice_memo = None if description_hash else memo
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts(
amount, user_wallet, currency, extra
)
if amount_sat > settings.lnbits_max_incoming_payment_amount_sats:
raise InvoiceError(
f"Invoice amount {amount_sat} sats is too high. Max allowed: "
f"{settings.lnbits_max_incoming_payment_amount_sats} sats.",
status="failed",
)
if settings.is_wallet_max_balance_exceeded(
user_wallet.balance_msat / 1000 + amount_sat
):
raise InvoiceError(
f"Wallet balance cannot exceed "
f"{settings.lnbits_wallet_limit_max_balance} sats.",
status="failed",
)
(
ok,
checking_id,
payment_request,
error_message,
) = 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 ok or not payment_request or not checking_id:
raise InvoiceError(
error_message or "unexpected backend error.", status="pending"
)
invoice = bolt11_decode(payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_request,
payment_hash=invoice.payment_hash,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
return payment
async def update_pending_payments(wallet_id: str):
pending_payments = await get_payments(
wallet_id=wallet_id,
pending=True,
exclude_uncheckable=True,
)
for payment in pending_payments:
await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> bool:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
return True
if status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
return True
return False
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal)
def fee_reserve(amount_msat: int, internal: bool = False) -> int:
return settings.fee_reserve(amount_msat, internal)
def service_fee(amount_msat: int, internal: bool = False) -> int:
amount_msat = abs(amount_msat)
service_fee_percent = settings.lnbits_service_fee
fee_max = settings.lnbits_service_fee_max * 1000
if settings.lnbits_service_fee_wallet:
if internal and settings.lnbits_service_fee_ignore_internal:
return 0
fee_percentage = int(amount_msat / 100 * service_fee_percent)
if fee_max > 0 and fee_percentage > fee_max:
return fee_max
else:
return fee_percentage
else:
return 0
async def update_wallet_balance(
wallet: Wallet,
amount: int,
conn: Optional[Connection] = None,
):
if amount == 0:
raise ValueError("Amount cannot be 0.")
# negative balance change
if amount < 0:
if wallet.balance + amount < 0:
raise ValueError("Balance change failed, can not go into negative balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment_secret, payment_hash = random_secret_and_hash()
invoice = Bolt11(
currency="bc",
amount_msat=MilliSatoshi(abs(amount) * 1000),
date=int(time.time()),
tags=Tags.from_dict(
{
"payment_hash": payment_hash,
"payment_secret": payment_secret,
"description": "Admin debit",
}
),
)
privkey = fake_privkey(settings.fake_wallet_secret)
bolt11 = bolt11_encode(invoice, privkey)
await create_payment(
checking_id=f"internal_{payment_hash}",
data=CreatePayment(
wallet_id=wallet.id,
bolt11=bolt11,
payment_hash=payment_hash,
amount_msat=amount * 1000,
memo="Admin debit",
),
status=PaymentState.SUCCESS,
conn=conn,
)
return None
# positive balance change
if (
settings.lnbits_wallet_limit_max_balance > 0
and wallet.balance + amount > settings.lnbits_wallet_limit_max_balance
):
raise ValueError("Balance change failed, amount exceeds maximum balance.")
async with db.reuse_conn(conn) if conn else db.connect() as conn:
payment = await create_invoice(
wallet_id=wallet.id,
amount=amount,
memo="Admin credit",
internal=True,
conn=conn,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
await check_time_limit_between_transactions(wallet_id, conn)
await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
async def check_time_limit_between_transactions(
wallet_id: str, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
async def check_wallet_daily_withdraw_limit(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
if limit < 0:
raise ValueError("It is not allowed to spend funds from this server.")
payments = await get_payments(
since=int(time.time()) - 60 * 60 * 24,
outgoing=True,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
if len(payments) == 0:
return
total = 0
for pay in payments:
total += pay.amount
total = total - amount_msat
if limit * 1000 + total < 0:
raise ValueError(
"Daily withdrawal limit of "
+ str(settings.lnbits_wallet_limit_daily_max_withdraw)
+ " sats reached."
)
async def calculate_fiat_amounts(
amount: float,
wallet: Wallet,
currency: Optional[str] = None,
extra: Optional[dict] = None,
) -> tuple[int, dict]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts: dict = extra or {}
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
if currency != wallet_currency:
fiat_amounts["fiat_currency"] = currency
fiat_amounts["fiat_amount"] = round(amount, ndigits=3)
fiat_amounts["fiat_rate"] = amount_sat / amount
fiat_amounts["btc_rate"] = (amount / amount_sat) * 100_000_000
else:
amount_sat = int(amount)
if wallet_currency:
try:
if wallet_currency == currency:
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
fiat_amounts["wallet_fiat_currency"] = wallet_currency
fiat_amounts["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
fiat_amounts["wallet_fiat_rate"] = amount_sat / fiat_amount
fiat_amounts["wallet_btc_rate"] = (fiat_amount / amount_sat) * 100_000_000
except Exception as e:
logger.error(f"Error calculating fiat amount for wallet '{wallet.id}': {e}")
logger.debug(
f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
)
return amount_sat, fiat_amounts
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:
payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment:
return PaymentPendingStatus()
if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee)
return await payment.check_status()
async def get_payments_daily_stats(
filters: Filters[PaymentFilters],
) -> list[PaymentDailyStats]:
data_in, data_out = await get_daily_stats(filters)
balance_total: float = 0
_none = PaymentDailyStats(date=datetime.now(timezone.utc))
if len(data_in) == 0 and len(data_out) == 0:
return []
if len(data_in) == 0:
data_in = [_none]
if len(data_out) == 0:
data_out = [_none]
data: list[PaymentDailyStats] = []
start_date = min(data_in[0].date, data_out[0].date)
end_date = max(data_in[-1].date, data_out[-1].date)
delta = timedelta(days=1)
while start_date <= end_date:
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 x.date == start_date), _none)
balance_total += data_in_point.balance + data_out_point.balance
data.append(
PaymentDailyStats(
date=start_date,
balance=balance_total // 1000,
balance_in=data_in_point.balance // 1000,
balance_out=data_out_point.balance // 1000,
payments_count=data_in_point.payments_count
+ data_out_point.payments_count,
count_in=data_in_point.payments_count,
count_out=data_out_point.payments_count,
fee=(data_in_point.fee + data_out_point.fee) // 1000,
)
)
start_date += delta
return data
async def _pay_invoice(wallet, create_payment_model, conn):
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(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Optional[Payment]:
"""
Pay an internal payment.
returns None if the payment is not internal.
"""
# check_internal() returns the payment of the invoice we're waiting for
# (pending only)
internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn
)
if not internal_payment:
return None
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn
)
if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed")
amount_msat = create_payment_model.amount_msat
if (
internal_invoice.amount != abs(amount_msat)
or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
):
raise PaymentError("Invalid invoice. Bolt11 changed.", status="failed")
fee_reserve_total_msat = fee_reserve_total(abs(amount_msat), internal=True)
create_payment_model.fee = abs(fee_reserve_total_msat)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError("Insufficient balance.", status="failed")
internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment(
checking_id=internal_id,
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}")
await send_payment_notification(wallet, payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
return payment
async def _pay_external_invoice(
wallet: Wallet,
create_payment_model: CreatePayment,
conn: Optional[Connection] = None,
) -> Payment:
checking_id = create_payment_model.payment_hash
amount_msat = create_payment_model.amount_msat
fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
if wallet.balance_msat < abs(amount_msat) + fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
status="failed",
)
# check if there is already a payment with the same checking_id
old_payment = await get_standalone_payment(checking_id, conn=conn)
if old_payment:
return await _verify_external_payment(old_payment, conn)
create_payment_model.fee = -abs(fee_reserve_total_msat)
payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
# make sure a hold invoice or deferred payment is not blocking the server
try:
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
payment_response = await asyncio.wait_for(task, wait_time)
except asyncio.TimeoutError:
# return pending payment on timeout
logger.debug(f"payment timeout, {checking_id} is still pending")
return payment
if payment_response.checking_id and payment_response.checking_id != checking_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {checking_id} got:"
f" {payment_response.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
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
else:
logger.warning(
"didn't receive checking_id from backend, payment may be stuck in"
f" database: {checking_id}"
)
return payment
async def _fundingsource_pay_invoice(
checking_id: str, bolt11: str, fee_reserve_msat: int
) -> PaymentResponse:
logger.debug(f"fundingsource: sending payment {checking_id}")
funding_source = get_funding_source()
payment_response: PaymentResponse = await funding_source.pay_invoice(
bolt11, fee_reserve_msat
)
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
return payment_response
async def _verify_external_payment(
payment: Payment, conn: Optional[Connection] = None
) -> Payment:
# fail on pending payments
if payment.pending:
raise PaymentError("Payment is still pending.", status="pending")
if payment.success:
raise PaymentError("Payment already paid.", status="success")
# payment failed
status = await payment.check_status()
if status.failed:
raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed"
)
if status.success:
# payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
)
# status.pending fall through and try again
return payment
async def _check_wallet_for_payment(
wallet_id: str,
tag: str,
amount_msat: int,
conn: Optional[Connection] = None,
):
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
# check if the payment is made for an extension that the user disabled
status = await check_user_extension_access(wallet.user, tag, conn=conn)
if not status.success:
raise PaymentError(status.message)
await check_wallet_limits(wallet_id, amount_msat, conn)
return wallet
def _validate_payment_request(
payment_request: str, max_sat: Optional[int] = None
) -> Bolt11:
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
raise PaymentError("Bolt11 decoding failed.", status="failed") from exc
if not invoice.amount_msat or not invoice.amount_msat > 0:
raise PaymentError("Amountless invoices not supported.", status="failed")
max_sat = max_sat or settings.lnbits_max_outgoing_payment_amount_sats
max_sat = min(max_sat, settings.lnbits_max_outgoing_payment_amount_sats)
if invoice.amount_msat > max_sat * 1000:
raise PaymentError(
f"Invoice amount {invoice.amount_msat // 1000} sats is too high. "
f"Max allowed: {max_sat} sats.",
status="failed",
)
return invoice
async def _credit_service_fee_wallet(
payment: Payment, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
payment_hash=payment.payment_hash,
amount_msat=abs(service_fee_msat),
memo="Service fee",
)
await create_payment(
checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
conn=conn,
)
-58
View File
@@ -1,58 +0,0 @@
from cryptography.hazmat.primitives import serialization
from loguru import logger
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.db import dict_to_model
from lnbits.settings import (
EditableSettings,
UpdateSettings,
readonly_variables,
settings,
)
from ..crud import update_admin_settings
async def check_webpush_settings():
if not settings.lnbits_webpush_privkey:
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
)
push_settings = {
"lnbits_webpush_privkey": privkey.decode(),
"lnbits_webpush_pubkey": pubkey,
}
update_cached_settings(push_settings)
if settings.lnbits_admin_ui:
await update_admin_settings(EditableSettings(**push_settings))
logger.info("Initialized webpush settings with generated VAPID key pair.")
logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}")
def dict_to_settings(sets_dict: dict) -> UpdateSettings:
return dict_to_model(sets_dict, UpdateSettings)
def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_settings(sets_dict)
for key in sets_dict.keys():
if key in readonly_variables:
continue
if key not in settings.dict().keys():
continue
try:
value = getattr(editable_settings, key)
setattr(settings, key, value)
except Exception:
logger.warning(f"Failed overriding setting: {key}.")
if "super_user" in sets_dict:
settings.super_user = sets_dict["super_user"]
-189
View File
@@ -1,189 +0,0 @@
from pathlib import Path
from typing import Optional
from uuid import uuid4
from loguru import logger
from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
from ..crud import (
create_account,
create_admin_settings,
create_user_extension,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_super_settings,
get_user_extensions,
get_user_from_account,
update_account,
update_super_user,
update_user_extension,
)
from ..helpers import to_valid_user_id
from ..models import (
Account,
User,
UserExtra,
)
from .settings import update_cached_settings
async def create_user_account(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
return await create_user_account_no_ckeck(account, wallet_name)
async def create_user_account_no_ckeck(
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if account:
account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
if not account.id:
account.id = uuid4().hex
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
for ext_id in settings.lnbits_user_default_extensions:
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
return user
async def update_user_account(account: Account) -> Account:
account.validate_fields()
existing_account = await get_account(account.id)
if not existing_account:
raise ValueError("User does not exist.")
account.password_hash = existing_account.password_hash
if existing_account.username and not account.username:
raise ValueError("Cannot remove username.")
if account.username:
existing_account = await get_account_by_username(account.username)
if existing_account and existing_account.id != account.id:
raise ValueError("Username already exists.")
elif existing_account.username:
raise ValueError("Cannot remove username.")
if account.email:
existing_account = await get_account_by_email(account.email)
if existing_account and existing_account.id != account.id:
raise ValueError("Email already exists.")
if account.pubkey:
existing_account = await get_account_by_pubkey(account.pubkey)
if existing_account and existing_account.id != account.id:
raise ValueError("Pubkey already exists.")
return await update_account(account)
async def update_user_extensions(user_id: str, extensions: list[str]):
user_extensions = await get_user_extensions(user_id)
for user_ext in user_extensions:
if user_ext.active:
if user_ext.extension not in extensions:
user_ext.active = False
await update_user_extension(user_ext)
else:
if user_ext.extension in extensions:
user_ext.active = True
await update_user_extension(user_ext)
user_extension_ids = [ue.extension for ue in user_extensions]
for ext in extensions:
if ext in user_extension_ids:
continue
user_extension = UserExtension(user=user_id, extension=ext, active=True)
await create_user_extension(user_extension)
async def check_admin_settings():
if settings.super_user:
settings.super_user = to_valid_user_id(settings.super_user).hex
if settings.lnbits_admin_ui:
settings_db = await get_super_settings()
if not settings_db:
# create new settings if table is empty
logger.warning("Settings DB empty. Inserting default settings.")
settings_db = await init_admin_settings(settings.super_user)
logger.warning("Initialized settings from environment variables.")
if settings.super_user and settings.super_user != settings_db.super_user:
# .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict())
# saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
"✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` "
"to get the superuser."
)
async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings:
account = None
if super_user:
account = await get_account(super_user)
if not account:
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
-27
View File
@@ -1,27 +0,0 @@
from fastapi import WebSocket
from loguru import logger
class WebsocketConnectionManager:
def __init__(self) -> None:
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket, item_id: str):
logger.debug(f"Websocket connected to {item_id}")
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(data, item_id)
+152 -131
View File
@@ -1,175 +1,196 @@
import asyncio
import traceback
from typing import Callable, Coroutine
from typing import Dict
import httpx
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_balance_notify,
get_wallet,
get_webpush_subscriptions_for_user,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
from lnbits.core.db import db
from lnbits.core.models import Payment
from lnbits.core.services import (
get_balance_delta,
)
from lnbits.core.services.notifications import (
enqueue_notification,
process_next_notification,
send_payment_notification,
switch_to_voidwallet,
)
from lnbits.settings import get_wallet_class, settings
from lnbits.tasks import (
SseListenersDict,
create_permanent_task,
create_task,
register_invoice_listener,
send_push_notification,
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue = asyncio.Queue()
api_invoice_listeners: Dict[str, asyncio.Queue] = SseListenersDict(
"api_invoice_listeners"
)
async def run_by_the_minute_tasks():
minute_counter = 0
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
def register_killswitch():
"""
Registers a killswitch which will check lnbits-status repository for a signal from
LNbits and will switch to VoidWallet if the killswitch is triggered.
"""
logger.debug("Starting killswitch task")
create_permanent_task(killswitch_task)
if settings.notification_balance_delta_changed:
async def killswitch_task():
while True:
WALLET = get_wallet_class()
if settings.lnbits_killswitch and WALLET.__class__.__name__ != "VoidWallet":
with httpx.Client() as client:
try:
r = client.get(settings.lnbits_status_manifest, timeout=4)
r.raise_for_status()
if r.status_code == 200:
ks = r.json().get("killswitch")
if ks and ks == 1:
logger.error(
"Switching to VoidWallet. Killswitch triggered."
)
await switch_to_voidwallet()
except (httpx.ConnectError, httpx.RequestError):
logger.error(
"Cannot fetch lnbits status manifest."
f" {settings.lnbits_status_manifest}"
)
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
async def register_watchdog():
"""
Registers a watchdog which will check lnbits balance and nodebalance
and will switch to VoidWallet if the watchdog delta is reached.
"""
# TODO: implement watchdog properly
# logger.debug("Starting watchdog task")
# create_permanent_task(watchdog_task)
async def watchdog_task():
while True:
WALLET = get_wallet_class()
if settings.lnbits_watchdog and WALLET.__class__.__name__ != "VoidWallet":
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions()
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
delta, *_ = await get_balance_delta()
logger.debug(f"Running watchdog task. current delta: {delta}")
if delta + settings.lnbits_watchdog_delta <= 0:
logger.error(f"Switching to VoidWallet. current delta: {delta}")
await switch_to_voidwallet()
except Exception as e:
logger.error("Error in watchdog task", e)
await asyncio.sleep(settings.lnbits_watchdog_interval * 60)
async def _notify_server_status():
accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count()
payments = await get_payments_status_count()
status = await get_balance_delta()
values = {
"up_time": settings.lnbits_server_up_time,
"accounts_count": accounts.total,
"wallets_count": wallets_count,
"in_payments_count": payments.incoming,
"out_payments_count": payments.outgoing,
"pending_payments_count": payments.pending,
"failed_payments_count": payments.failed,
"delta_sats": status.delta_sats,
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
}
enqueue_notification(NotificationType.server_status, values)
def register_task_listeners():
"""
Registers an invoice listener queue for the core tasks. Incoming payments in this
queue will eventually trigger the signals sent to all other extensions
and fulfill other core tasks such as dispatching webhooks.
"""
invoice_paid_queue = asyncio.Queue(5)
# we register invoice_paid_queue to receive all incoming invoices
register_invoice_listener(invoice_paid_queue, "core/tasks.py")
# register a worker that will react to invoices
create_task(wait_for_paid_invoices(invoice_paid_queue))
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,
dispatches webhooks and balance notifys.
"""
while settings.lnbits_running:
while True:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# payment notification
# send information to sse channel
await dispatch_api_invoice_listeners(payment)
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
# dispatch balance_notify
url = await get_balance_notify(payment.wallet_id)
if url:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.post(url, timeout=4)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
pass
await send_payment_push_notification(payment)
async def wait_for_audit_data():
async def dispatch_api_invoice_listeners(payment: Payment):
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
Emits events to invoice listener subscribed from the API.
"""
while settings.lnbits_running:
data: AuditEntry = await audit_queue.get()
for chan_name, send_channel in api_invoice_listeners.items():
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
logger.debug(f"sending invoice paid event to {chan_name}")
send_channel.put_nowait(payment)
except asyncio.QueueFull:
logger.error(f"removing sse listener {send_channel}:{chan_name}")
api_invoice_listeners.pop(chan_name)
async def wait_notification_messages():
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
while settings.lnbits_running:
if not payment.webhook:
return await mark_webhook_sent(payment, -1)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try:
await process_next_notification()
except Exception as ex:
logger.log("error", ex)
await asyncio.sleep(3)
r = await client.post(payment.webhook, json=data, timeout=40)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
async def purge_audit_data():
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
# clean every hour
await asyncio.sleep(60 * 60)
async def mark_webhook_sent(payment: Payment, status: int) -> None:
await db.execute(
"""
UPDATE apipayments SET webhook_status = ?
WHERE hash = ?
""",
(status, payment.payment_hash),
)
async def collect_exchange_rates_data():
"""
Collect exchange rates data. Used for monitoring only.
"""
while settings.lnbits_running:
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
async def send_payment_push_notification(payment: Payment):
wallet = await get_wallet(payment.wallet_id)
if sleep_time > 0:
try:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
else:
sleep_time = 60
await asyncio.sleep(sleep_time)
if wallet:
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
def _create_unique_task(name: str, func: Callable):
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
return await func()
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
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())
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)
-228
View File
@@ -1,228 +0,0 @@
<q-tab-panel name="audit">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">Audit</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('enable_audit')"></q-item-label>
<q-item-label caption v-text="$t('audit_desc')"></q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-6 col-sm-12">
<q-input
filled
v-model="formData.lnbits_audit_retention_days"
type="number"
label="Retention days"
hint="Number of days to keep the audit entry."
>
</q-input>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row">
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_request_body"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('audit_record_req')"></q-item-label>
<q-item-label caption>
<span v-text="$t('audit_record_warning')"></span>
<br />
<ul>
<li><span v-text="$t('audit_record_req_warning_1')"></span></li>
<li><span v-text="$t('audit_record_req_warning_2')"></span></li>
</ul>
<br />
<span v-text="$t('audit_record_use')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_ip_address"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_ip')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('audit_ip_desc')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_path_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_path_params')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('recommended')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
<div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_audit_log_query_params"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label>
<span v-text="$t('audit_query_params')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('recommended')"></span>
</q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_http_methods')"></span></p>
<q-select
filled
v-model="formData.lnbits_audit_http_methods"
multiple
:hint="$t('audit_http_methods_hint')"
:label="$t('audit_http_methods_label')"
:options="['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']"
></q-select>
</div>
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_resp_codes')"></span></p>
<q-input
class="q-mb-md"
filled
v-model="formAddIncludeResponseCode"
@keydown.enter="addIncludeResponseCode"
type="text"
:label="$t('audit_resp_codes_label')"
:hint="$t('audit_resp_codes_hint')"
>
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="code in formData.lnbits_audit_http_response_codes"
:key="code"
removable
@remove="removeIncludeResponseCode(code)"
color="primary"
text-color="white"
:label="code"
>
</q-chip>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_paths')"></span></p>
<q-input
class="q-mb-md"
filled
v-model="formAddIncludePath"
@keydown.enter="addIncludePath"
type="text"
:label="$t('audit_paths_label')"
:hint="$t('audit_paths_hint')"
>
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_include_paths"
:key="path"
removable
@remove="removeIncludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
</div>
<div class="col-md-6 col-sm-12">
<p>
<span v-text="$t('audit_paths_exclude')"></span>
</p>
<q-input
class="q-mb-md"
filled
v-model="formAddExcludePath"
@keydown.enter="addExcludePath"
type="text"
:label="$t('audit_paths_exclude_label')"
:hint="$t('audit_paths_exclude_hint')"
>
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="path in formData.lnbits_audit_exclude_paths"
:key="path"
removable
@remove="removeExcludePath(path)"
color="primary"
text-color="white"
:label="path"
>
</q-chip>
</div>
<br />
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,197 +0,0 @@
<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,111 +0,0 @@
<q-tab-panel name="extensions">
<q-card-section class="q-pa-none">
<div>
<h6 class="q-my-none">
<span v-text="$t('extensions')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 q-mb-md">
<p>
<span v-text="$t('extension_sources')"></span>
</p>
<q-input
class="q-mb-md"
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
:label="$t('ext_sources_label')"
:hint="$t('ext_sources_hint')"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="manifestUrl"></span
></q-chip>
</div>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>
<span v-text="$t('admin_extensions')"></span>
</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
:label="$t('admin_extensions_label')"
:hint="$t('admin_extensions_hint')"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('user_default_extensions')"></span>
</p>
<q-select
filled
v-model="formData.lnbits_user_default_extensions"
multiple
:label="$t('user_default_extensions_label')"
:hint="$t('user_default_extensions_hint')"
:options="g.extensions"
></q-select>
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('miscellaneous')"></span>
</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_disable_extensions')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('misc_disable_extensions_label')"></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_deactivate_all"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_hide_api')"></span>
</q-item-label>
<q-item-label caption>
<span v-text="$t('misc_hide_api_label')"></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
+49 -149
View File
@@ -1,196 +1,96 @@
<q-tab-panel name="funding">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">
<span v-text="$t('wallets_management')"></span>
</h6>
<h6 class="q-my-none">Wallets Management</h6>
<br />
<div>
<div class="row">
<div class="col">
<p>
<span v-text="$t('funding_source_info')"></span>
</p>
<p>Funding Source Info</p>
<ul>
<li
v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
></li>
<li
v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
></li>
<li
v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
></li>
<li
v-text="$t('funding_reserve_percent', {
percent: auditData.lnbits_balance_sats > 0
? (auditData.node_balance_sats / auditData.lnbits_balance_sats * 100).toFixed(2)
: 100
})"
></li>
{%raw%}
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
<li>
Node Balance: {{(auditData.node_balance_msats /
1000).toLocaleString()}} sats
</li>
<li>
LNbits Balance: {{(auditData.lnbits_balance_msats /
1000).toLocaleString()}} sats
</li>
<li>
Reserve Percent: {{(auditData.node_balance_msats /
auditData.lnbits_balance_msats * 100).toFixed(2)}} %
</li>
{%endraw%}
</ul>
<br />
</div>
<div class="col">
{% if LNBITS_NODE_UI_AVAILABLE %}
<p><span v-text="$t('node_management')"></span></p>
<p>Node Management</p>
<q-toggle
:label="$t('toggle_node_ui')"
label="Node UI"
v-model="formData.lnbits_node_ui"
></q-toggle>
<q-toggle
v-if="formData.lnbits_node_ui"
:label="$t('toggle_public_node_ui')"
label="Public node UI"
v-model="formData.lnbits_public_node_ui"
></q-toggle>
<br />
<q-toggle
v-if="formData.lnbits_node_ui"
:label="$t('toggle_transactions_node_ui')"
label="Transactions Tab (Disable on large CLN nodes)"
v-model="formData.lnbits_node_ui_transactions"
></q-toggle>
{% else %}
<p><span v-text="$t('node_management_not_supported')"></span></p>
<p>Node Management not supported by active funding source</p>
{% endif %}
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<p><span v-text="$t('fee_reserve')"></span></p>
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
:label="$t('fee_reserve_msats')"
>
</q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('fee_reserve_percent')"></span></p>
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
:label="$t('reserve_fee_in_percent')"
step="0.1"
></q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('invoice_expiry')"></span></p>
<div class="col-12 col-md-4">
<p>Invoice Expiry</p>
<q-input
filled
v-model.number="formData.lightning_invoice_expiry"
type="number"
:label="$t('invoice_expiry_label')"
label="Invoice expiry (seconds)"
mask="#######"
>
</q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('payment_wait_time')"></span></p>
<q-input
type="number"
filled
name="lnbits_funding_source_pay_invoice_wait_seconds"
v-model="formData.lnbits_funding_source_pay_invoice_wait_seconds"
:label="$t('payment_wait_time')"
:hint="$t('payment_wait_time_desc')"
step="1"
min="0"
></q-input>
<div class="col-12 col-md-8">
<p>Fee reserve</p>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-input
type="number"
filled
v-model="formData.lnbits_reserve_fee_min"
label="Reserve fee in msats"
>
</q-input>
</div>
<div class="col-6">
<q-input
type="number"
filled
name="lnbits_reserve_fee_percent"
v-model="formData.lnbits_reserve_fee_percent"
label="Reserve fee in percent"
step="0.1"
></q-input>
</div>
</div>
</div>
</div>
<div v-if="isSuperUser">
<div v-if="isSuperUser" id="funding-sources">
<lnbits-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
</div>
<q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog_switch_to_voidwallet"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_watchdog_limit')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_watchdog_limit_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval_minutes"
type="number"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -1,481 +0,0 @@
<q-tab-panel name="notifications">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<q-separator class="q-mt-md q-mb-sm"></q-separator>
<div class="row q-col-gutter-md">
<div class="col-sm-12 col-md-6">
<strong v-text="$t('notifications_nostr_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_nostr')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_nostr_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_nostr_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_private_key')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_private_key_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_nostr_notifications_private_key"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_nostr_identifiers')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_nostr_identifiers_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="nostrNotificationIdentifier"
@keydown.enter="addNostrNotificationIdentifier"
>
<q-btn
@click="addNostrNotificationIdentifier()"
dense
flat
icon="add"
></q-btn>
</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>
</div>
<div class="col-sm-12 col-md-6">
<strong v-text="$t('notifications_telegram_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_telegram')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_telegram_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_telegram_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_telegram_access_token')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_telegram_access_token_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_telegram_notifications_access_token"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('notifications_chat_id')"></q-item-label>
<q-item-label
caption
v-text="$t('notifications_chat_id_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_telegram_notifications_chat_id"
/>
</q-item-section>
</q-item>
</div>
<div class="col-sm-12">
<q-separator></q-separator>
</div>
<div class="col-12">
<strong v-text="$t('notifications_email_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_email_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<div v-if="formData.lnbits_email_notifications_enabled" class="row">
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_email"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_password')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_password_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_password"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_to_emails')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_to_emails_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="emailNotificationAddress"
@keydown.enter="addEmailNotificationAddress"
>
<q-btn
@click="addEmailNotificationAddress()"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="to_email in formData.lnbits_email_notifications_to_emails"
:key="to_email"
removable
@remove="removeEmailNotificationAddress(to_email)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="to_email"></span
></q-chip>
</div>
</q-item-section>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_server_port')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_server_port_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_port"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_server')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_server_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_server"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
<q-separator> </q-separator>
<h6 class="q-mb-sm">
<span v-text="$t('notifications')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_settings_update')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_settings_update_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_settings_update"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_credit_debit')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_credit_debit_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_credit_debit"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_server_start_stop')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_server_start_stop_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_server_start_stop"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_balance_delta_changed')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_balance_delta_changed_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.notification_balance_delta_changed"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_watchdog_limit')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_watchdog_limit_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notification_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_server_status')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_server_status_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-input
class="flow-right"
type="number"
min="0"
filled
v-model="formData.lnbits_notification_server_status_hours"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_incoming_payment')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_incoming_payment_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-input
class="flow-right"
type="number"
min="0"
filled
v-model="formData.lnbits_notification_incoming_payment_amount_sats"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notification_outgoing_payment')"
></q-item-label>
<q-item-label
caption
v-text="$t('notification_outgoing_payment_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-input
class="flow-right"
type="number"
min="0"
filled
v-model="formData.lnbits_notification_outgoing_payment_amount_sats"
/>
</q-item-section>
</q-item>
</div>
</div>
</q-card-section>
</q-tab-panel>
+187 -100
View File
@@ -1,77 +1,29 @@
<q-tab-panel name="security">
<q-card-section class="q-pa-none">
<h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
<h6 class="q-my-none q-mb-sm">Authentication</h6>
<div class="row">
<div class="col-md-6">
<p><span v-text="$t('base_url')"></span></p>
<q-input
filled
v-model.number="formData.lnbits_baseurl"
:label="$t('base_url_label')"
></q-input>
<br />
</div>
</div>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('authentication')"></span>
</h6>
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-input
filled
v-model="formData.auth_token_expire_minutes"
type="number"
:label="$t('auth_token_expiry_label')"
:hint="$t('auth_token_expiry_hint')"
label="Token expire minutes"
hint="Time in minutes until the token expires"
>
</q-input>
</div>
<div class="col-12 col-sm-6">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-select
filled
v-model="formData.auth_allowed_methods"
multiple
:hint="$t('auth_allowed_methods_hint')"
:label="$t('auth_allowed_methods_label')"
hint="Allowed authorization methods"
label="Select authorization methods"
:options="formData.auth_all_methods"
></q-select>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('nostr-auth-nip98')"
class="q-pl-xl"
>
<strong class="q-my-none q-mb-sm">Nostr Auth</strong>
<div class="row">
<div class="col-12">
<q-input
class="q-mb-sm"
filled
v-model="nostrAcceptedUrl"
@keydown.enter="addNostrUrl"
type="text"
:label="$t('auth_nostr_label')"
:hint="$t('auth_nostr_hint')"
>
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
</q-input>
</div>
<div class="col-12">
<q-chip
v-for="url in formData.nostr_absolute_request_urls"
:key="url"
removable
@remove="removeNostrUrl(url)"
color="primary"
text-color="white"
:label="url"
class="ellipsis"
></q-chip>
</div>
</div>
</q-card-section>
<q-card-section
v-if="formData.auth_allowed_methods?.includes('google-auth')"
class="q-pl-xl"
@@ -83,8 +35,8 @@
<q-input
filled
v-model="formData.google_client_id"
:label="$t('auth_google_ci_label')"
:hint="$t('auth_google_ci_hint')"
label="Google Client ID"
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
>
</q-input>
</div>
@@ -93,7 +45,7 @@
filled
v-model="formData.google_client_secret"
type="password"
:label="$t('auth_google_cs_label')"
label="Google Client Secret"
>
</q-input>
</div>
@@ -110,8 +62,8 @@
<q-input
filled
v-model="formData.github_client_id"
:label="$t('auth_gh_client_id_label')"
:hint="$t('auth_gh_client_id_hint')"
label="GitHub Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
>
</q-input>
</div>
@@ -120,7 +72,7 @@
filled
v-model="formData.github_client_secret"
type="password"
:label="$t('auth_gh_client_secret_label')"
label="GitHub Client Secret"
>
</q-input>
</div>
@@ -137,7 +89,7 @@
<q-input
filled
v-model="formData.keycloak_discovery_url"
:label="$t('auth_keycloak_label')"
label="Keycloak Discovey URL"
>
</q-input>
</div>
@@ -145,8 +97,8 @@
<q-input
filled
v-model="formData.keycloak_client_id"
:label="$t('auth_keycloak_ci_label')"
:hint="$t('auth_keycloak_ci_hint')"
label="Keycloak Client ID"
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
>
</q-input>
</div>
@@ -155,7 +107,7 @@
filled
v-model="formData.keycloak_client_secret"
type="password"
:label="$t('auth_keycloak_cs_label')"
label="Keycloak Client Secret"
>
</q-input>
</div>
@@ -179,7 +131,7 @@
style="padding: 10px; color: #fafafa; height: 320px"
>
<small v-for="log in logs"
><span v-text="log"></span><br
>{% raw %}{{ log }}{% endraw %}<br
/></small>
</q-scroll-area>
</div>
@@ -214,6 +166,7 @@
></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="blocked_ip in formData.lnbits_blocked_ips"
:key="blocked_ip"
@@ -221,9 +174,10 @@
@remove="removeBlockedIPs(blocked_ip)"
color="primary"
text-color="white"
:label="blocked_ip"
class="ellipsis"
></q-chip>
>
{{ blocked_ip }}
</q-chip>
{%endraw%}
</div>
<br />
</div>
@@ -244,6 +198,7 @@
></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="allowed_ip in formData.lnbits_allowed_ips"
:key="allowed_ip"
@@ -251,9 +206,10 @@
@remove="removeAllowedIPs(allowed_ip)"
color="primary"
text-color="white"
:label="allowed_ip"
class="ellipsis"
></q-chip>
>
{{ allowed_ip }}
</q-chip>
{%endraw%}
</div>
<br />
</div>
@@ -283,41 +239,172 @@
</div>
<div class="col-12 col-md-12">
<p v-text="$t('callback_url_rules')"></p>
<p v-text="$t('wallet_limiter')"></p>
<div class="row q-col-gutter-md">
<div class="col-12">
<div class="col-4">
<q-input
filled
v-model="formCallbackUrlRule"
@keydown.enter="addCallbackUrlRule"
type="text"
:label="$t('enter_callback_url_rule')"
:hint="$t('callback_url_rule_hint')"
>
<q-btn
@click="addCallbackUrlRule"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="rule in formData.lnbits_callback_url_rules"
:key="rule"
removable
@remove="removeCallbackUrlRule(rule)"
color="primary"
text-color="white"
:label="rule"
class="ellipsis"
></q-chip>
</div>
<br />
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-4">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
</div>
</div>
</div>
</div>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_notifications')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_notifications_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_notifications"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
<p
v-if="!formData.lnbits_notifications"
v-text="$t('notifications_disabled')"
></p>
<div v-if="formData.lnbits_notifications">
{% include "admin/_tab_security_notifications.html" %}
</div>
<br />
<div>
<p v-text="$t('notification_source')"></p>
<q-input
filled
v-model="formData.lnbits_status_manifest"
type="text"
:label="$t('notification_source_label')"
/>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p v-text="$t('killswitch')"></p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('enable_killswitch')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_killswitch_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_killswitch"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('killswitch_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('killswitch_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_killswitch_interval"
type="number"
/>
</q-item-section>
</q-item>
<br />
<p v-text="$t('watchdog')"></p>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('enable_watchdog')"></q-item-label>
<q-item-label
caption
v-text="$t('enable_watchdog_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_watchdog"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_interval')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_interval_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_interval"
type="number"
/>
</q-item-section>
</q-item>
<q-item disabled tag="label" v-ripple>
<q-tooltip><span v-text="$t('coming_soon')"></span></q-tooltip>
<q-item-section>
<q-item-label v-text="$t('watchdog_delta')"></q-item-label>
<q-item-label
caption
v-text="$t('watchdog_delta_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.lnbits_watchdog_delta"
type="number"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
@@ -0,0 +1,72 @@
{% raw %}
<q-banner v-if="updateAvailable" class="bg-primary text-white">
<q-icon size="28px" name="update"></q-icon>
<span v-text="$t('update_available', {version: statusData.version})"></span>
<template v-slot:action>
<a
class="q-btn"
color="white"
target="_blank"
rel="noopener noreferrer"
href="https://github.com/lnbits/lnbits/releases"
v-text="$t('releases')"
></a>
</template>
</q-banner>
<q-banner v-if="!updateAvailable" class="bg-green text-white">
<q-icon size="28px" name="checkmark"></q-icon>
<span v-text="$t('latest_update', {version: statusData.version})"></span>
</q-banner>
<q-card>
<q-card-section>
<q-table
dense
flat
:data="statusData.notifications"
:columns="statusDataTable.columns"
:no-data-label="$t('no_notifications')"
>
<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"
>{{ col.label }}</q-th
>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="text-center">
<q-icon
v-if="props.row.type === 'update'"
size="18px"
name="update"
color="green"
></q-icon>
<q-icon
v-if="props.row.type === 'warning'"
size="18px"
name="warning"
color="red"
></q-icon>
</q-td>
<q-td auto-width key="date" :props="props">
{{ formatDate(props.row.date) }}
</q-td>
<q-td key="message" :props="props"
>{{ props.row.message }}
<a
v-if="props.row.link"
target="_blank"
rel="noopener noreferrer"
:href="props.row.link"
v-text="$t('more')"
></a
></q-td>
</q-tr>
</template>
</q-table>
</q-card-section>
</q-card>
{% endraw %}
+108 -92
View File
@@ -1,144 +1,130 @@
<q-tab-panel name="server">
<q-card-section class="q-pa-none">
<h6 class="q-my-none">Server Management</h6>
<br />
<div>
<h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
<div class="row">
<div class="col">
<p>Server Info</p>
<ul>
{%raw%}
<li v-if="settings.lnbits_data_folder">
SQlite: {{settings.lnbits_data_folder}}
</li>
<li v-if="settings.lnbits_database_url">
Postgres: {{settings.lnbits_database_url}}
</li>
{%endraw%}
</ul>
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_currencies')"></span></p>
<p>Allowed currencies</p>
<q-select
filled
v-model="formData.lnbits_allowed_currencies"
multiple
:hint="$t('allowed_currencies_hint')"
:label="$t('allowed_currencies')"
hint="Limit the number of available fiat currencies"
label="Allowed currencies"
:options="{{ currencies | safe }}"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('default_account_currency')"></span></p>
<p>Default Accounting Currency</p>
<q-select
filled
v-model="formData.lnbits_default_accounting_currency"
clearable
:hint="$t('default_account_currency_hint')"
:label="$t('currency')"
hint="Default currency for accounting"
label="Currency"
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
></q-select>
<br />
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('payments')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<p><span v-text="$t('max_outgoing_payment_amount')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_max_outgoing_payment_amount_sats"
:label="$t('max_outgoing_payment_amount')"
step="1"
min="0"
:hint="$t('max_outgoing_payment_amount_desc')"
></q-input>
</div>
<div class="col-12 col-md-3">
<p><span v-text="$t('max_incoming_payment_amount')"></span></p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_max_incoming_payment_amount_sats"
:label="$t('max_incoming_payment_amount')"
step="1"
min="0"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-12 col-md-6"></div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('wallet_limiter')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_max_balance"
:label="$t('wallet_max_ballance')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_daily_max_withdraw"
:label="$t('wallet_limit_max_withdraw_per_day')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-input
filled
type="number"
v-model.number="formData.lnbits_wallet_limit_secs_between_trans"
:label="$t('wallet_limit_secs_between_trans')"
></q-input>
</div>
<div class="col-12 col-md-3">
<q-toggle
v-model="formData.lnbits_only_allow_incoming_payments"
:label="$t('only_incoming_payments_allowed')"
><q-tooltip v-text="$t('disable_outgoing_payments')"></q-tooltip
></q-toggle>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none"><span v-text="$t('service_fee')"></span></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('service_fee')"></span></p>
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.code)"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Miscellaneous</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Hide API</q-item-label>
<q-item-label caption
>Hides wallet api, extensions can choose to honor</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_hide_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<br />
</div>
</div>
<br />
<h6 class="q-my-none">Service Fee</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Service Fee</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee"
:label="$t('service_fee_label')"
label="Service fee (%)"
step="0.1"
:hint="$t('service_fee_hint')"
hint="Fee charged per tx (%)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('service_fee_max')"></span></p>
<p>Service fee max</p>
<q-input
filled
type="number"
v-model.number="formData.lnbits_service_fee_max"
:label="$t('service_fee_max_label')"
:hint="$t('service_fee_max_hint')"
label="Service fee max (sats)"
hint="Max service fee to charge in (sats)"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('fee_wallet')"></span></p>
<p>Fee Wallet</p>
<q-input
filled
v-model="formData.lnbits_service_fee_wallet"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
label="Fee wallet (wallet ID)"
hint="Wallet ID to send funds to"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('disable_fee_internal')"></span></p>
<p>Disable Service Fee for Internal Payments</p>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('disable_fee')"></q-item-label>
<q-item-label
caption
v-text="$t('disable_fee_internal_desc')"
></q-item-label>
<q-item-label>Disable Fee</q-item-label>
<q-item-label caption
>Disable Service Fee for Internal Lightning
Payments</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
@@ -153,6 +139,36 @@
<br />
</div>
</div>
<h6 class="q-my-none">Extensions</h6>
<div>
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
>
{{ manifestUrl }}
</q-chip>
{%endraw%}
</div>
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>
+30 -121
View File
@@ -1,219 +1,128 @@
<q-tab-panel name="site_customisation">
<q-tab-panel name="theme">
<q-card-section class="q-pa-none">
<h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
<h6 class="q-my-none">UI Management</h6>
<br />
<div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_title')"></span></p>
<div class="col-12 col-md-6">
<p>Site Title</p>
<q-input
filled
type="text"
v-model="formData.lnbits_site_title"
:label="$t('ui_site_title')"
label="Site title"
></q-input>
<br />
</div>
<div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_tagline')"></span></p>
<div class="col-12 col-md-6">
<p>Site Tagline</p>
<q-input
filled
type="text"
v-model="formData.lnbits_site_tagline"
:label="$t('ui_site_tagline')"
label="Site tagline"
></q-input>
<br />
</div>
<div class="col-12 col-md-2 q-mt-xl">
<q-toggle
:tip="$t('ui_toggle_elements_tip')"
v-model="formData.lnbits_show_home_page_elements"
:label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
></q-toggle>
</div>
</div>
<div>
<p><span v-text="$t('ui_site_description')"></span></p>
<p>Site Description</p>
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
:hint="$t('ui_site_description_hint')"
hint="Use plain text, Markdown, or raw HTML"
/>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_default_wallet_name')"></span></p>
<p>Default Wallet Name</p>
<q-input
filled
type="text"
v-model="formData.lnbits_default_wallet_name"
:label="$t('lnbits_wallet')"
label="LNbits wallet"
></q-input>
<br />
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('denomination')"></span></p>
<p>Denomination</p>
<q-input
filled
type="text"
v-model="formData.lnbits_denomination"
label="sats"
:hint="$t('denomination_hint')"
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
hint="The name for the FakeWallet token"
></q-input>
<br />
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<p>QR code logo</p>
<q-input
filled
type="text"
v-model="formData.lnbits_qr_logo"
label="https://example.com/image.svg"
:hint="$t('ui_qr_code_logo_hint')"
hint="URL to logo image in QR code"
></q-input>
<br />
</div>
</div>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_badge')"></span></p>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8">
<q-input
filled
type="text"
tip="Custom Badge"
v-model.trim="formData.lnbits_custom_badge"
:label="$t('ui_custom_badge_label')"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-select
filled
v-model="formData.lnbits_custom_badge_color"
:options="colors"
:label="$t('ui_custom_badge_color_label')"
></q-select>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('ui_custom_image')"></span></p>
<q-input
filled
type="text"
tip="Custom Image"
v-model.trim="formData.lnbits_custom_image"
:label="$t('ui_custom_image_label')"
:hint="$t('ui_custom_image_hint')"
></q-input>
</div>
</div>
<br />
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('themes')"></span></p>
<p>Themes</p>
<q-select
filled
v-model="formData.lnbits_theme_options"
multiple
:hint="$t('themes_hint')"
hint="Choose themes available for users"
:options="lnbits_theme_options"
:label="$t('themes')"
label="Themes"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('custom_logo')"></span></p>
<p>Custom Logo</p>
<q-input
filled
type="text"
v-model="formData.lnbits_custom_logo"
label="https://example.com/image.png"
:hint="$t('custom_logo_hint')"
hint="URL to logo image"
></q-input>
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p><span v-text="$t('ad_space_title')"></span></p>
<p>Ad Space Title</p>
<q-input
filled
type="text"
v-model="formData.lnbits_ad_space_title"
:label="$t('ad_space_title_label')"
label="Supported by"
></q-input>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('ad_slots')"></span></p>
<p>Advertisement Slots</p>
<q-input
class="q-mb-md"
filled
v-model="formData.lnbits_ad_space"
type="text"
:label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')"
label="url;img_light_url;img_dark_url, url..."
hint="Ad url and image filepaths in CSV format, extensions can choose to honor"
>
</q-input>
<q-toggle
v-model="formData.lnbits_ad_space_enabled"
:label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('ads_disabled')"
:label="formData.lnbits_ad_space_enabled ? 'Ads enabled' : 'Ads disabled'"
/>
<br />
</div>
</div>
<p><span v-text="$t('ui_default_theme')"></span></p>
<div class="row q-col-gutter-md">
<div class="col">
<q-select
v-model="formData.lnbits_default_border"
:options="globalBorderOptions"
label="Border"
@update:model-value="applyGlobalBorder"
>
</q-select>
</div>
<div class="col">
<q-select
v-model="formData.lnbits_default_theme"
:options="lnbits_theme_options"
label="Theme"
@update:model-value="applyGlobalTheme"
>
</q-select>
</div>
<div class="col">
<q-select
v-model="formData.lnbits_default_reaction"
:options="reactionOptions"
label="Payment reaction"
@update:model-value="applyGlobalReaction"
>
</q-select>
</div>
<div class="col">
<q-input
type="text"
v-model="formData.lnbits_default_bgimage"
label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
>
</q-input>
</div>
<div class="col">
<q-toggle
type="bool"
v-model="formData.lnbits_default_gradient"
color="primary"
label="Gradient Background"
>
</q-toggle>
</div>
</div>
</div>
</q-card-section>
</q-tab-panel>
+15 -20
View File
@@ -1,19 +1,17 @@
<q-tab-panel name="users">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('user_management')"></span>
</h6>
<h6 class="q-my-none q-mb-sm">User Management</h6>
<div class="row">
<div class="col-12 col-md-6 q-pr-sm">
<p><span v-text="$t('admin_users')"></span></p>
<div class="col-md-6 col-sm-12 q-pr-sm">
<p>Admin Users</p>
<q-input
filled
v-model="formAddAdmin"
@keydown.enter="addAdminUser"
type="text"
:label="$t('admin_users_label')"
:hint="$t('admin_users_hint')"
label="User ID"
hint="Users with admin privileges"
>
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
</q-input>
@@ -25,22 +23,21 @@
@remove="removeAdminUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
v-text="user"
>
</q-chip>
</div>
<br />
</div>
<div class="col-12 col-md-6">
<p><span v-text="$t('allowed_users')"></span></p>
<div class="col-md-6 col-sm-12">
<p>Allowed Users</p>
<q-input
filled
v-model="formAddUser"
@keydown.enter="addAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="$t('allowed_users_hint')"
label="User ID"
hint="Only these users can use LNbits"
>
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
</q-input>
@@ -52,19 +49,17 @@
@remove="removeAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
v-text="user"
>
</q-chip>
</div>
<br />
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('allow_creation_user')"></q-item-label>
<q-item-label
caption
v-text="$t('allow_creation_user_desc')"
></q-item-label>
<q-item-label>Allow creation of new users</q-item-label>
<q-item-label caption
>Allow creation of new users on the index page</q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
+132 -189
View File
@@ -1,231 +1,174 @@
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col q-my-md">
<q-btn
:label="$t('save')"
color="primary"
@click="updateSettings"
:disabled="!checkChanges"
>
<q-tooltip v-if="checkChanges"
>{%raw%}{{ $t('save_tooltip') }}{%endraw%}</q-tooltip
>
<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-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"
>
<q-tooltip v-if="needsRestart"
>{%raw%}{{ $t('restart_tooltip') }}{%endraw%}</q-tooltip
>
<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-badge
v-if="needsRestart"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
<q-btn
v-if="isSuperUser"
:label="$t('topup')"
color="primary"
@click="topUpDialog.show = true"
>
<q-tooltip>{%raw%}{{ $t('add_funds_tooltip') }}{%endraw%}</q-tooltip>
</q-btn>
<q-btn
:label="$t('download_backup')"
flat
@click="downloadBackup"
></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>
<q-btn
flat
v-if="isSuperUser"
:label="$t('reset_defaults')"
color="primary"
@click="deleteSettings"
class="float-right"
>
<q-tooltip>{%raw%}{{ $t('reset_defaults_tooltip') }}{%endraw%}</q-tooltip>
</q-btn>
</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"
>
<div class="q-pa-md">
<div class="q-gutter-y-md">
<q-tabs v-model="tab" active-color="primary" align="justify">
<q-tab
name="funding"
icon="account_balance_wallet"
:label="$q.screen.gt.sm ? $t('funding') : null"
:label="$t('funding')"
@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"
:label="$t('users')"
@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"
name="server"
:label="$t('server')"
@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"
name="security"
:label="$t('security')"
@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"
name="theme"
:label="$t('theme')"
@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
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>
</div>
</div>
<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"%}
</q-tab-panels>
</q-form>
</template>
</q-splitter>
<q-form name="settings_form" id="settings_form">
<q-tab-panels v-model="tab" animated>
{% include "admin/_tab_funding.html" %} {% include
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
include "admin/_tab_security.html" %} {% include
"admin/_tab_theme.html" %}
</q-tab-panels>
</q-form>
</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>
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<p v-text="$t('topup_wallet')"></p>
<div class="row">
<div class="col-12">
<q-input
dense
type="text"
filled
v-model="wallet.id"
label="Wallet ID"
:hint="$t('topup_hint')"
></q-input>
<br />
</div>
<div class="col-12">
<q-input
dense
type="number"
filled
v-model="wallet.amount"
:label="$t('amount')"
></q-input>
</div>
</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 class="row q-mt-lg">
<q-btn
:label="$t('topup')"
color="primary"
@click="topupWallet"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('cancel')"
></q-btn>
</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-form>
</q-card>
</q-dialog>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
<style>
.introjs-tooltip-custom .introjs-tooltiptext,
.introjs-tooltip-header {
color: #111;
}
</style>
{% endblock %}
-201
View File
@@ -1,201 +0,0 @@
{% 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 %}
+25 -116
View File
@@ -5,75 +5,9 @@
: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>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
</q-card-section>
<q-expansion-item
group="api"
@@ -85,7 +19,7 @@
<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="wallet.inkey"></i>"}</code><br />
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
@@ -95,13 +29,12 @@
>
<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="wallet.inkey"></i>"</code
>curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
@@ -112,7 +45,7 @@
<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="wallet.inkey"></i>"}</code><br />
<code>{"X-Api-Key": "<i>{{ 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;,
@@ -128,10 +61,9 @@
>
<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="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -144,23 +76,9 @@
>
<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
>
<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="adminkeyHidden ? '****************' : wallet.adminkey"
></i
>"}</code
>
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</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">
@@ -169,10 +87,10 @@
<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
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
@@ -190,16 +108,18 @@
><span class="text-light-green">POST</span>
/api/v1/payments/decode</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"data": &lt;string&gt;}</code>
<code>{"invoice": &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
>curl -X POST {{ request.base_url }}api/v1/payments/decode -d
'{"data": &lt;bolt11/lnurl, string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section>
</q-card>
@@ -225,22 +145,11 @@
<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="wallet.inkey"></i>" -H "Content-type:
application/json"</code
>curl -X GET {{ request.base_url
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</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>
+57 -517
View File
@@ -1,46 +1,32 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
{% extends "base.html" %}
<!---->
{% 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>
{% block scripts %} {{ window_vars(user) }}
<script src="{{ static_url_for('static', 'js/account.js') }}"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-tabs v-model="tab" align="justify">
<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-tabs>
<q-tab-panels v-model="tab">
<q-tab-panel name="user">
<div v-if="credentialsData.show">
<div v-if="passwordData.show">
<q-card-section>
<div class="row">
<div class="col">
@@ -50,26 +36,19 @@
</div>
<div class="col">
<q-img
v-if="user.extra.picture"
v-if="user.config.picture"
style="max-width: 100px"
:src="user.extra.picture"
:src="user.config.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<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"
v-model="passwordData.oldPassword"
type="password"
autocomplete="off"
label="Old Password"
@@ -78,7 +57,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="credentialsData.newPassword"
v-model="passwordData.newPassword"
type="password"
autocomplete="off"
:label="$t('password')"
@@ -87,7 +66,7 @@
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
<q-input
v-model="credentialsData.newPasswordRepeat"
v-model="passwordData.newPasswordRepeat"
type="password"
autocomplete="off"
:label="$t('password_repeat')"
@@ -96,62 +75,41 @@
class="q-mb-md"
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
></q-input>
</q-card-section>
<q-separator></q-separator>
<q-card-section class="q-pb-lg">
<q-btn
@click="updatePassword"
:disable="disableUpdatePassword()"
:disable="(!passwordData.newPassword || !passwordData.newPasswordRepeat) || passwordData.newPassword !== passwordData.newPasswordRepeat"
unelevated
color="primary"
class="float-right"
:label="$t('change_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">
<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"
@click="passwordData.show = false"
:label="$t('back')"
outline
unelevated
color="grey"
class="float-right"
></q-btn>
</q-card-section>
</div>
<div v-else>
<q-card-section v-if="user.extra.picture">
<q-card-section>
<div class="row">
<div class="col">
<q-img
v-if="user.config.picture"
style="max-width: 100px"
:src="user.extra.picture"
:src="user.config.picture"
class="float-right"
></q-img>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-input
@@ -179,15 +137,6 @@
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')"
@@ -246,9 +195,9 @@
</div>
</q-card-section>
<q-card-section v-if="user.extra">
<q-card-section v-if="user.config">
<q-input
v-model="user.extra.first_name"
v-model="user.config.first_name"
:label="$t('first_name')"
filled
dense
@@ -256,7 +205,7 @@
>
</q-input>
<q-input
v-model="user.extra.last_name"
v-model="user.config.last_name"
:label="$t('last_name')"
filled
dense
@@ -264,7 +213,7 @@
>
</q-input>
<q-input
v-model="user.extra.provider"
v-model="user.config.provider"
:label="$t('auth_provider')"
filled
dense
@@ -273,9 +222,10 @@
>
</q-input>
<q-input
v-model="user.extra.picture"
v-model="user.config.picture"
:label="$t('picture')"
filled
dense
class="q-mb-md"
>
</q-input>
@@ -286,25 +236,17 @@
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
filled
color="primary"
@click="showChangePassword()"
:label="user.has_password ? $t('change_password'): $t('set_password')"
outline
unelevated
color="grey"
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">
@@ -344,7 +286,7 @@
v-if="g.allowedThemes.includes('classic')"
dense
flat
@click="themeChoiceFunc('classic')"
@click="changeColor('classic')"
icon="circle"
color="deep-purple"
size="md"
@@ -354,7 +296,7 @@
v-if="g.allowedThemes.includes('bitcoin')"
dense
flat
@click="themeChoiceFunc('bitcoin')"
@click="changeColor('bitcoin')"
icon="circle"
color="orange"
size="md"
@@ -364,7 +306,7 @@
v-if="g.allowedThemes.includes('mint')"
dense
flat
@click="themeChoiceFunc('mint')"
@click="changeColor('mint')"
icon="circle"
color="green"
size="md"
@@ -373,7 +315,7 @@
v-if="g.allowedThemes.includes('autumn')"
dense
flat
@click="themeChoiceFunc('autumn')"
@click="changeColor('autumn')"
icon="circle"
color="brown"
size="md"
@@ -383,7 +325,7 @@
v-if="g.allowedThemes.includes('monochrome')"
dense
flat
@click="themeChoiceFunc('monochrome')"
@click="changeColor('monochrome')"
icon="circle"
color="grey"
size="md"
@@ -393,7 +335,7 @@
v-if="g.allowedThemes.includes('salvador')"
dense
flat
@click="themeChoiceFunc('salvador')"
@click="changeColor('salvador')"
icon="circle"
color="blue-10"
size="md"
@@ -403,7 +345,7 @@
v-if="g.allowedThemes.includes('freedom')"
dense
flat
@click="themeChoiceFunc('freedom')"
@click="changeColor('freedom')"
icon="circle"
color="pink-13"
size="md"
@@ -413,7 +355,7 @@
v-if="g.allowedThemes.includes('cyber')"
dense
flat
@click="themeChoiceFunc('cyber')"
@click="changeColor('cyber')"
icon="circle"
color="light-green-9"
size="md"
@@ -423,7 +365,7 @@
v-if="g.allowedThemes.includes('flamingo')"
dense
flat
@click="themeChoiceFunc('flamingo')"
@click="changeColor('flamingo')"
icon="circle"
color="pink-3"
size="md"
@@ -431,43 +373,6 @@
</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>
@@ -488,26 +393,7 @@
</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-4">Notifications</div>
<div class="col-8">
<lnbits-notifications-btn
v-if="g.user"
@@ -521,10 +407,10 @@
</div>
<div class="col-8">
<q-select
v-model="reactionSelection"
v-model="reactionChoice"
:options="reactionOptions"
label="Reactions"
@update:model-value="reactionChoiceFunc"
@input="reactionChoiceFunc"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
@@ -533,230 +419,6 @@
</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>
@@ -771,126 +433,4 @@
</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 %}
File diff suppressed because it is too large Load Diff

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