Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
395ba3aae6 | ||
|
|
ec77a00f49 | ||
|
|
991e0db50b | ||
|
|
fe9b62e8a8 | ||
|
|
c5964436b5 | ||
|
|
4ce14e2312 | ||
|
|
7021add193 | ||
|
|
4be01a405c | ||
|
|
ebe9ff700f | ||
|
|
a3619d40c6 | ||
|
|
048aff3db4 | ||
|
|
89d85a0d7e | ||
|
|
5aa1f9b0f8 | ||
|
|
5dc1705fa6 | ||
|
|
0fc657c55c | ||
|
|
8641b34a3d | ||
|
|
30cf6913af | ||
|
|
10682cf736 | ||
|
|
ee725846c5 | ||
|
|
69b31b1480 | ||
|
|
73a57ecf97 | ||
|
|
ab0f52d811 | ||
|
|
8c59f3f4ae | ||
|
|
1bfb09ddab | ||
|
|
2af5ac9678 | ||
|
|
4511891297 | ||
|
|
eeca7de10d | ||
|
|
b06c16ed57 | ||
|
|
2f2a3b1092 | ||
|
|
9fe63b49d6 | ||
|
|
6754ffb9dc | ||
|
|
4afe6dbd6d | ||
|
|
0cd94e654e | ||
|
|
d1692a18dd | ||
|
|
0305d740ee | ||
|
|
05777ca953 | ||
|
|
1e083856ed | ||
|
|
c08623277e | ||
|
|
60de308c8f | ||
|
|
2aebe72df8 | ||
|
|
2bd7600bcf | ||
|
|
e318f7679e | ||
|
|
0912d7d1c8 | ||
|
|
bfa23568e3 | ||
|
|
b76d8b5458 | ||
|
|
1cef614349 | ||
|
|
16fbfd1c6f | ||
|
|
15ba3c33a3 | ||
|
|
b33a1903e8 | ||
|
|
2d41a1bed3 | ||
|
|
4d490506f3 | ||
|
|
ed3a54e57a | ||
|
|
61e8d0059b | ||
|
|
4a640dc123 | ||
|
|
1f4160fda5 | ||
|
|
dc91fbea9b | ||
|
|
7eb021f43c | ||
|
|
69350849b1 | ||
|
|
828261d696 | ||
|
|
90dd3cc73a | ||
|
|
10c60291d2 | ||
|
|
9d2e52f694 | ||
|
|
503a599341 | ||
|
|
a8809ed5a4 | ||
|
|
e134c5c7b9 | ||
|
|
0efb52664e | ||
|
|
53b00c6d41 | ||
|
|
9a1d707f54 | ||
|
|
736699af94 | ||
|
|
432b3a0fe0 | ||
|
|
34a959f0bc | ||
|
|
c1d26bb274 | ||
|
|
b24a65cf2a | ||
|
|
960c58db87 | ||
|
|
dd9b217fdf | ||
|
|
7af9da4a58 | ||
|
|
c23eae9e08 | ||
|
|
66b2ca2ac4 | ||
|
|
25e5423511 | ||
|
|
4c7567a552 | ||
|
|
73e716085b | ||
|
|
cc33a49b12 | ||
|
|
eeebaffbed | ||
|
|
8633891485 | ||
|
|
cb5cc1e5e8 | ||
|
|
f845bfe651 | ||
|
|
b6bdf50ed7 | ||
|
|
56a4b702f3 | ||
|
|
273ab9781f | ||
|
|
8a759c8fc1 | ||
|
|
c7ebe09463 | ||
|
|
5d4d22dcdd | ||
|
|
b164317121 | ||
|
|
f415a92914 | ||
|
|
16c78f1901 | ||
|
|
6a08d20fe8 | ||
|
|
5abfbdd07a | ||
|
|
bba7614012 | ||
|
|
2ee7180aa0 | ||
|
|
982732cbd4 | ||
|
|
dfdb34852c | ||
|
|
8fa5c81088 | ||
|
|
91748fc891 | ||
|
|
0574837950 | ||
|
|
7e27b2c6f0 | ||
|
|
6f3d0edfbd | ||
|
|
3900d2871d | ||
|
|
0c5b909c7a | ||
|
|
78437eaf94 | ||
|
|
8d1542d982 | ||
|
|
146a74761a | ||
|
|
31664a4ba0 | ||
|
|
f985a1eb08 | ||
|
|
4e5b001901 | ||
|
|
3a2cf12052 | ||
|
|
f17ff3d12b | ||
|
|
32c042741d | ||
|
|
d234ab12d8 | ||
|
|
37187bfc2c | ||
|
|
368da935db | ||
|
|
81aef06f75 | ||
|
|
27241e01f3 | ||
|
|
3ff85363f2 | ||
|
|
b81b30c896 | ||
|
|
25d59e4142 | ||
|
|
5f8ccee5b8 | ||
|
|
740180d332 | ||
|
|
b66a8b3de9 | ||
|
|
b3351f2b17 | ||
|
|
36dbdaa159 | ||
|
|
524a4c9213 | ||
|
|
200b9b127c | ||
|
|
646c341840 | ||
|
|
826f8ad2ac | ||
|
|
1b9bb32ca0 | ||
|
|
61c71660b8 | ||
|
|
80ac158ca0 | ||
|
|
291c69e470 | ||
|
|
8ff4962e86 | ||
|
|
57e9d2271d | ||
|
|
c18816bf1b | ||
|
|
8f290a647d | ||
|
|
d1cc317044 | ||
|
|
5a1d660c7b | ||
|
|
7ee78248b7 | ||
|
|
5104cbb285 | ||
|
|
fa71219ce7 | ||
|
|
f5f43e7668 | ||
|
|
4b63d0924e | ||
|
|
dc22088dcd |
+15
-12
@@ -1,5 +1,17 @@
|
||||
#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 ###########
|
||||
######################################
|
||||
@@ -14,7 +26,7 @@ LNBITS_ADMIN_UI=true
|
||||
|
||||
# Change theme
|
||||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
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."
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
@@ -133,17 +145,8 @@ BREEZ_GREENLIGHT_SEED=SEED
|
||||
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, 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"
|
||||
|
||||
# Google OAuth Config
|
||||
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||
@@ -183,7 +186,7 @@ LNBITS_ADMIN_USERS=""
|
||||
# SUPER_USER=""
|
||||
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
|
||||
# Extensions enabled by default when a user is created
|
||||
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ inputs:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
default: "1.7.0"
|
||||
@@ -47,6 +47,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Name=LNbits
|
||||
Exec=lnbits
|
||||
Icon=lnbits
|
||||
Type=Application
|
||||
Categories=X-Bitcoin;X-LightningNetwork;X-Finance;Network;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -16,7 +16,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
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:
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
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:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
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:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
uses: ./.github/workflows/migration.yml
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
python-version: ["3.10"]
|
||||
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
|
||||
with:
|
||||
custom-pytest: "poetry run pytest tests/regtest"
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
needs: [ lint ]
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9"]
|
||||
python-version: ["3.10"]
|
||||
poetry-version: ["1.5.1"]
|
||||
uses: ./.github/workflows/jmeter.yml
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
|
||||
jobs:
|
||||
push_to_dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
python-version:
|
||||
description: "Python Version"
|
||||
required: true
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
type: string
|
||||
poetry-version:
|
||||
description: "Poetry Version"
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
jmeter:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -31,9 +31,18 @@ jobs:
|
||||
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
|
||||
run: |
|
||||
poetry run lnbits &
|
||||
sleep 5
|
||||
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
|
||||
@@ -43,6 +52,13 @@ jobs:
|
||||
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() }}
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkblack
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: checkruff
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: mypy
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: ./.github/workflows/make.yml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10"]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
with:
|
||||
make: pyright
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: ${{ inputs.make }} (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
os-version: ["ubuntu-24.04"]
|
||||
node-version: ["18.x"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
steps:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: migration (${{ inputs.python-version }})
|
||||
strategy:
|
||||
matrix:
|
||||
os-version: ["ubuntu-latest"]
|
||||
os-version: ["ubuntu-24.04"]
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
services:
|
||||
postgres:
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v27
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Build LNbits AppImage DMG
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
|
||||
build-linux-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Step 1: Checkout the repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Step 2: Install Dependencies
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
shell: bash
|
||||
|
||||
# Step 3: Clone LNbits Repository
|
||||
- name: Clone LNbits
|
||||
run: |
|
||||
mv .github/packaging packaging
|
||||
mkdir -p packaging/linux/AppDir/usr
|
||||
git clone https://github.com/lnbits/lnbits.git packaging/linux/AppDir/usr/lnbits
|
||||
shell: bash
|
||||
|
||||
# Step 4: Make the AppImage Asset
|
||||
- name: Make Asset
|
||||
run: |
|
||||
cd packaging/linux/AppDir/usr/lnbits
|
||||
poetry install
|
||||
poetry run pip install pyinstaller
|
||||
|
||||
# Build the LNbits binary
|
||||
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
|
||||
cd ../../../../..
|
||||
chmod +x packaging/linux/AppDir/AppRun
|
||||
chmod +x packaging/linux/AppDir/lnbits.desktop
|
||||
chmod +x packaging/linux/AppDir/usr/lnbits/dist/lnbits
|
||||
|
||||
find packaging/linux/AppDir/usr/lnbits -mindepth 1 -maxdepth 1 \
|
||||
! -name 'dist' \
|
||||
! -name 'lnbits' \
|
||||
-exec rm -rf {} +
|
||||
|
||||
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x appimagetool-x86_64.AppImage
|
||||
TAG_NAME=${{ github.event.release.tag_name }}
|
||||
APPIMAGE_NAME="LNbits-${TAG_NAME}.AppImage"
|
||||
./appimagetool-x86_64.AppImage --updateinformation "gh-releases-zsync|lnbits|lnbits|latest|*.AppImage.zsync" packaging/linux/AppDir "$APPIMAGE_NAME"
|
||||
chmod +x "$APPIMAGE_NAME"
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
# Step 5: Upload Linux Release Asset
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -8,10 +8,10 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
default: "ubuntu-24.04"
|
||||
type: string
|
||||
backend-wallet-class:
|
||||
required: true
|
||||
@@ -63,6 +63,8 @@ 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
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
|
||||
@@ -12,7 +12,7 @@ permissions:
|
||||
jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
pypi:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Install dependencies for building secp256k1
|
||||
run: |
|
||||
|
||||
@@ -8,10 +8,10 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
python-version:
|
||||
default: "3.9"
|
||||
default: "3.10"
|
||||
type: string
|
||||
os-version:
|
||||
default: "ubuntu-latest"
|
||||
default: "ubuntu-24.04"
|
||||
type: string
|
||||
db-url:
|
||||
default: ""
|
||||
|
||||
+3
-1
@@ -39,6 +39,8 @@ 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
|
||||
@@ -51,7 +53,7 @@ lnbits-backup.zip
|
||||
|
||||
# Ignore extensions (post installable extension PR)
|
||||
/lnbits/extensions
|
||||
/upgrades/
|
||||
/lnbits/upgrades/
|
||||
|
||||
# builded python package
|
||||
dist
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
FROM python:3.10-slim-bookworm AS builder
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y curl pkg-config build-essential libnss-myhostname
|
||||
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
@@ -21,7 +21,7 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
|
||||
RUN poetry install --only main
|
||||
|
||||
FROM python:3.10-slim-bookworm
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# needed for backups postgresql-client version 14 (pg_dump)
|
||||
RUN apt-get update && apt-get -y upgrade && \
|
||||
@@ -32,7 +32,7 @@ RUN apt-get update && apt-get -y upgrade && \
|
||||
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 -
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
ENV POETRY_NO_INTERACTION=1 \
|
||||
|
||||
@@ -67,7 +67,7 @@ You can access your super user account at `/wallet?usr=super_user_id`. You just
|
||||
|
||||
After that you will find the **`Admin` / `Manage Server`** between `Wallets` and `Extensions`
|
||||
|
||||
Here you can design the interface, it has TOPUP to fill wallets and you can restrict access rights to extensions only for admins or generally deactivated for everyone. You can make users admins or set up Allowed Users if you want to restrict access. And of course the classic settings of the .env file, e.g. to change the funding source wallet or set a charge fee.
|
||||
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.
|
||||
|
||||
Do not forget
|
||||
|
||||
|
||||
+37
-35
@@ -6,38 +6,54 @@ nav_order: 2
|
||||
|
||||
# Basic installation
|
||||
|
||||
The following sections explain how to install LNbits using varions package managers: `poetry`, `nix`, `Docker` and `Fly.io`.
|
||||
|
||||
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.
|
||||
|
||||
## Option 1 (recommended): Poetry
|
||||
## Option 1: AppImage (LInux)
|
||||
|
||||
It is recommended to use the latest version of Poetry. Make sure you have Python version 3.9 or higher installed.
|
||||
### AppImage (Linux)
|
||||
|
||||
### Verify Python version
|
||||
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
|
||||
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"
|
||||
# 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
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits
|
||||
poetry env use 3.12
|
||||
git checkout main
|
||||
|
||||
poetry install --only main
|
||||
|
||||
cp .env.example .env
|
||||
# set funding source amongst other options
|
||||
nano .env
|
||||
# Optional: to set funding source amongst other options via the env `nano .env`
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
@@ -49,9 +65,16 @@ poetry run lnbits
|
||||
# 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
|
||||
@@ -60,18 +83,6 @@ poetry install --only main
|
||||
# Start LNbits with `poetry run lnbits`
|
||||
```
|
||||
|
||||
## Option 2: Install script (on Debian/Ubuntu)
|
||||
|
||||
```sh
|
||||
wget https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits.sh &&
|
||||
chmod +x lnbits.sh &&
|
||||
./lnbits.sh
|
||||
```
|
||||
|
||||
Now visit `0.0.0.0:5000` to make a super-user account.
|
||||
|
||||
`export PATH="/home/$USER/.local/bin:$PATH"` then `./lnbits.sh` can be used to run, but for more control `cd lnbits` and use `poetry run lnbits` (see previous option).
|
||||
|
||||
## Option 3: Nix
|
||||
|
||||
```sh
|
||||
@@ -215,7 +226,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.9-dev gcc build-essential
|
||||
sudo apt install python3.10-dev gcc build-essential
|
||||
|
||||
# if the secp256k1 build fails:
|
||||
# if you used poetry
|
||||
@@ -348,15 +359,6 @@ 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
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1723938990,
|
||||
"narHash": "sha256-9tUadhnZQbWIiYVXH8ncfGXGvkNq3Hag4RCBEMUk7MI=",
|
||||
"lastModified": 1735563628,
|
||||
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c42fcfbdfeae23e68fc520f9182dde9f38ad1890",
|
||||
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -34,6 +34,16 @@
|
||||
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
|
||||
];
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check install has not already run
|
||||
if [ ! -d lnbits/data ]; then
|
||||
|
||||
# Update package list and install prerequisites non-interactively
|
||||
sudo apt update -y
|
||||
sudo apt install -y software-properties-common
|
||||
|
||||
# Add the deadsnakes PPA repository non-interactively
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
|
||||
# Install Python 3.9 and distutils non-interactively
|
||||
sudo apt install -y python3.9 python3.9-distutils
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3.9 -
|
||||
|
||||
# Add Poetry to PATH for the current session
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
if [ ! -d lnbits/wallets ]; then
|
||||
# Clone the LNbits repository
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to clone the repository ... FAIL"
|
||||
exit 1
|
||||
fi
|
||||
# Ensure we are in the lnbits directory
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
git checkout main
|
||||
# Make data folder
|
||||
mkdir data
|
||||
|
||||
# Copy the .env.example to .env
|
||||
cp .env.example .env
|
||||
|
||||
elif [ ! -d lnbits/wallets ]; then
|
||||
# cd into lnbits
|
||||
cd lnbits || { echo "Failed to cd into lnbits ... FAIL"; exit 1; }
|
||||
fi
|
||||
|
||||
# Set path for running after install
|
||||
export PATH="/home/$USER/.local/bin:$PATH"
|
||||
|
||||
# Install the dependencies using Poetry
|
||||
poetry env use python3.9
|
||||
poetry install --only main
|
||||
|
||||
# Set environment variables for LNbits
|
||||
export LNBITS_ADMIN_UI=true
|
||||
export HOST=0.0.0.0
|
||||
|
||||
# Run LNbits
|
||||
poetry run lnbits
|
||||
+53
-30
@@ -14,6 +14,7 @@ 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 lnbits.core.crud import (
|
||||
@@ -23,13 +24,17 @@ from lnbits.core.crud import (
|
||||
)
|
||||
from lnbits.core.crud.extensions import create_installed_extension
|
||||
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.tasks import ( # watchdog_task
|
||||
from lnbits.core.services.notifications import enqueue_notification
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
killswitch_task,
|
||||
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.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
@@ -54,7 +59,6 @@ from .core.models.extensions import Extension, ExtensionMeta, InstallableExtensi
|
||||
from .core.services import check_admin_settings, check_webpush_settings
|
||||
from .middleware import (
|
||||
AuditMiddleware,
|
||||
CustomGZipMiddleware,
|
||||
ExtensionsRedirectMiddleware,
|
||||
InstalledExtensionMiddleware,
|
||||
add_first_install_middleware,
|
||||
@@ -64,7 +68,6 @@ from .middleware import (
|
||||
from .requestvars import g
|
||||
from .tasks import (
|
||||
check_pending_payments,
|
||||
create_task,
|
||||
internal_invoice_listener,
|
||||
invoice_listener,
|
||||
)
|
||||
@@ -78,8 +81,13 @@ async def startup(app: FastAPI):
|
||||
|
||||
# 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
|
||||
@@ -96,11 +104,26 @@ async def startup(app: FastAPI):
|
||||
init_core_routers(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks(app)
|
||||
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
|
||||
@@ -149,10 +172,7 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CustomGZipMiddleware, minimum_size=1000, exclude_paths=["/api/v1/payments/sse"]
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
app.add_middleware(AuditMiddleware, audit_queue=audit_queue)
|
||||
|
||||
@@ -264,14 +284,14 @@ async def build_all_installed_extensions_list(
|
||||
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
|
||||
"""
|
||||
installed_extensions = await get_installed_extensions()
|
||||
settings.lnbits_all_extensions_ids = {e.id for e in 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_all_extensions_ids:
|
||||
if ext_id in settings.lnbits_installed_extensions_ids:
|
||||
continue
|
||||
ext_info = InstallableExtension.from_ext_dir(ext_id)
|
||||
if not ext_info:
|
||||
@@ -286,7 +306,7 @@ async def build_all_installed_extensions_list(
|
||||
logger.warning(e)
|
||||
|
||||
for ext_id in settings.lnbits_extensions_default_install:
|
||||
if ext_id in settings.lnbits_all_extensions_ids:
|
||||
if ext_id in settings.lnbits_installed_extensions_ids:
|
||||
continue
|
||||
|
||||
ext_releases = await InstallableExtension.get_extension_releases(ext_id)
|
||||
@@ -347,6 +367,11 @@ async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
|
||||
|
||||
|
||||
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")
|
||||
@@ -365,11 +390,6 @@ def register_custom_extensions_path():
|
||||
Path(extensions_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(extensions_dir))
|
||||
|
||||
upgrades_dir = Path(settings.lnbits_extensions_path, "upgrades")
|
||||
shutil.rmtree(upgrades_dir, True)
|
||||
Path(upgrades_dir).mkdir(parents=True, exist_ok=True)
|
||||
sys.path.append(str(upgrades_dir))
|
||||
|
||||
|
||||
def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
# Returns a function that registers new routes for an extension.
|
||||
@@ -394,16 +414,21 @@ 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:
|
||||
@@ -430,31 +455,29 @@ async def check_and_register_extensions(app: FastAPI):
|
||||
for ext in await get_valid_extensions(False):
|
||||
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}")
|
||||
|
||||
|
||||
def register_async_tasks(app: FastAPI):
|
||||
|
||||
# check extensions after restart
|
||||
if not settings.lnbits_extensions_deactivate_all:
|
||||
create_task(check_and_register_extensions(app))
|
||||
def register_async_tasks():
|
||||
|
||||
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)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue(5)
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
# TODO: implement watchdog properly
|
||||
# create_permanent_task(watchdog_task)
|
||||
create_permanent_task(killswitch_task)
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
@@ -338,12 +339,17 @@ async def extensions_update(
|
||||
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:
|
||||
|
||||
@@ -11,7 +11,6 @@ from .views.extension_api import extension_router
|
||||
from .views.generic import generic_router
|
||||
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
|
||||
@@ -31,7 +30,6 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(extension_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(payment_router)
|
||||
app.include_router(wallet_router)
|
||||
app.include_router(api_router)
|
||||
|
||||
@@ -58,6 +58,7 @@ from .users import (
|
||||
get_account_by_username_or_email,
|
||||
get_accounts,
|
||||
get_user,
|
||||
get_user_access_control_lists,
|
||||
get_user_from_account,
|
||||
update_account,
|
||||
)
|
||||
@@ -145,6 +146,7 @@ __all__ = [
|
||||
"get_accounts",
|
||||
"get_user",
|
||||
"get_user_from_account",
|
||||
"get_user_access_control_lists",
|
||||
"update_account",
|
||||
# wallets
|
||||
"create_wallet",
|
||||
|
||||
+156
-23
@@ -1,25 +1,23 @@
|
||||
from time import time
|
||||
from typing import Literal, Optional
|
||||
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 DB_TYPE, SQLITE, Connection, Filters, Page
|
||||
from lnbits.db import Connection, DateTrunc, Filters, Page
|
||||
|
||||
from ..models import (
|
||||
CreatePayment,
|
||||
Payment,
|
||||
PaymentCountField,
|
||||
PaymentCountStat,
|
||||
PaymentDailyStats,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentsStatusCount,
|
||||
PaymentWalletStats,
|
||||
)
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
"day": "%Y-%m-%d 00:00:00",
|
||||
"month": "%Y-%m-01 00:00:00",
|
||||
}
|
||||
|
||||
|
||||
def update_payment_extra():
|
||||
pass
|
||||
@@ -87,7 +85,7 @@ async def get_latest_payments_by_extension(
|
||||
WHERE status = '{PaymentState.SUCCESS}'
|
||||
AND extra LIKE :ext_name
|
||||
AND extra LIKE :ext_id
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
ORDER BY time DESC LIMIT {int(limit)}
|
||||
""",
|
||||
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
|
||||
Payment,
|
||||
@@ -99,6 +97,7 @@ 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,
|
||||
@@ -107,7 +106,8 @@ async def get_payments_paginated(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Page[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
Filters payments to be returned by:
|
||||
- complete | pending | failed | outgoing | incoming.
|
||||
"""
|
||||
|
||||
values: dict = {
|
||||
@@ -123,15 +123,22 @@ async def get_payments_paginated(
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
|
||||
if complete and pending:
|
||||
pass
|
||||
clause.append(
|
||||
f"(status = '{PaymentState.SUCCESS}' OR status = '{PaymentState.PENDING}')"
|
||||
)
|
||||
elif complete:
|
||||
clause.append(
|
||||
f"((amount > 0 AND status = '{PaymentState.SUCCESS}') OR amount < 0)"
|
||||
f"""
|
||||
(
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
)
|
||||
elif pending:
|
||||
clause.append(f"status = '{PaymentState.PENDING}'")
|
||||
else:
|
||||
pass
|
||||
elif failed:
|
||||
clause.append(f"status = '{PaymentState.FAILED}'")
|
||||
|
||||
if outgoing and incoming:
|
||||
pass
|
||||
@@ -195,6 +202,21 @@ async def get_payments(
|
||||
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:
|
||||
@@ -229,6 +251,7 @@ async def create_payment(
|
||||
# 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,
|
||||
@@ -242,7 +265,8 @@ async def create_payment(
|
||||
expiry=data.expiry,
|
||||
webhook=data.webhook,
|
||||
fee=data.fee,
|
||||
extra=data.extra or {},
|
||||
tag=extra.get("tag", None),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -279,18 +303,19 @@ async def get_payments_history(
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
if DB_TYPE == SQLITE and group in sqlite_formats:
|
||||
date_trunc = f"strftime('{sqlite_formats[group]}', time, 'unixepoch')"
|
||||
elif group in ("day", "hour", "month"):
|
||||
date_trunc = f"date_trunc('{group}', time)"
|
||||
else:
|
||||
raise ValueError(f"Invalid group value: {group}")
|
||||
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)"
|
||||
f"""
|
||||
wallet_id = :wallet_id AND (
|
||||
status = '{PaymentState.SUCCESS}'
|
||||
OR (amount < 0 AND status = '{PaymentState.PENDING}')
|
||||
)
|
||||
"""
|
||||
]
|
||||
transactions: list[dict] = await db.fetchall(
|
||||
f"""
|
||||
@@ -330,6 +355,114 @@ async def get_payments_history(
|
||||
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:
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -18,7 +19,8 @@ async def get_super_settings() -> Optional[SuperSettings]:
|
||||
if data:
|
||||
super_user = await get_settings_field("super_user")
|
||||
super_user_id = super_user.value if super_user else None
|
||||
return SuperSettings(**{"super_user": super_user_id, **data})
|
||||
settings_dict = {"super_user": super_user_id, **data}
|
||||
return dict_to_model(settings_dict, SuperSettings)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from datetime import datetime, timezone
|
||||
from time import time
|
||||
from typing import Optional
|
||||
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 (
|
||||
@@ -46,6 +47,20 @@ 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
|
||||
@@ -53,7 +68,6 @@ async def get_accounts(
|
||||
accounts.username,
|
||||
accounts.email,
|
||||
accounts.pubkey,
|
||||
wallets.id as wallet_id,
|
||||
SUM(COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
), 0)) as balance_msat,
|
||||
@@ -69,8 +83,8 @@ async def get_accounts(
|
||||
)) as last_payment
|
||||
FROM accounts LEFT JOIN wallets ON accounts.id = wallets.user
|
||||
""",
|
||||
[],
|
||||
{},
|
||||
where_clauses,
|
||||
values,
|
||||
filters=filters,
|
||||
model=AccountOverview,
|
||||
group_by=["accounts.id"],
|
||||
@@ -80,6 +94,8 @@ async def get_accounts(
|
||||
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},
|
||||
@@ -109,6 +125,8 @@ async def delete_accounts_no_wallets(
|
||||
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},
|
||||
@@ -129,6 +147,8 @@ async def get_account_by_pubkey(
|
||||
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},
|
||||
@@ -172,3 +192,20 @@ async def get_user_from_account(
|
||||
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)
|
||||
|
||||
@@ -23,6 +23,7 @@ async def create_wallet(
|
||||
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
|
||||
@@ -134,6 +135,12 @@ async def get_wallets(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -152,6 +159,6 @@ async def get_wallet_for_key(
|
||||
|
||||
|
||||
async def get_total_balance(conn: Optional[Connection] = None):
|
||||
result = await (conn or db).execute("SELECT SUM(balance) FROM balances")
|
||||
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
|
||||
row = result.mappings().first()
|
||||
return row.get("balance", 0)
|
||||
return row.get("balance", 0) or 0
|
||||
|
||||
+76
-49
@@ -9,7 +9,7 @@ from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db):
|
||||
async def m000_create_migrations_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dbversions (
|
||||
@@ -20,7 +20,7 @@ async def m000_create_migrations_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
async def m001_initial(db: Connection):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
@@ -89,7 +89,7 @@ async def m001_initial(db):
|
||||
)
|
||||
|
||||
|
||||
async def m002_add_fields_to_apipayments(db):
|
||||
async def m002_add_fields_to_apipayments(db: Connection):
|
||||
"""
|
||||
Adding fields to apipayments for better accounting,
|
||||
and renaming payhash to checking_id since that is what it really is.
|
||||
@@ -133,7 +133,7 @@ async def m002_add_fields_to_apipayments(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m003_add_invoice_webhook(db):
|
||||
async def m003_add_invoice_webhook(db: Connection):
|
||||
"""
|
||||
Special column for webhook endpoints that can be assigned
|
||||
to each different invoice.
|
||||
@@ -143,7 +143,7 @@ async def m003_add_invoice_webhook(db):
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
|
||||
|
||||
|
||||
async def m004_ensure_fees_are_always_negative(db):
|
||||
async def m004_ensure_fees_are_always_negative(db: Connection):
|
||||
"""
|
||||
Use abs() so wallet backends don't have to care about the sign of the fees.
|
||||
"""
|
||||
@@ -168,7 +168,7 @@ async def m004_ensure_fees_are_always_negative(db):
|
||||
)
|
||||
|
||||
|
||||
async def m005_balance_check_balance_notify(db):
|
||||
async def m005_balance_check_balance_notify(db: Connection):
|
||||
"""
|
||||
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 +198,7 @@ async def m005_balance_check_balance_notify(db):
|
||||
)
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db):
|
||||
async def m006_add_invoice_expiry_to_apipayments(db: Connection):
|
||||
"""
|
||||
Adds invoice expiry column to apipayments.
|
||||
"""
|
||||
@@ -208,7 +208,7 @@ async def m006_add_invoice_expiry_to_apipayments(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m007_set_invoice_expiries(db):
|
||||
async def m007_set_invoice_expiries(db: Connection):
|
||||
"""
|
||||
Precomputes invoice expiry for existing pending incoming payments.
|
||||
"""
|
||||
@@ -258,7 +258,7 @@ async def m007_set_invoice_expiries(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db):
|
||||
async def m008_create_admin_settings_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
@@ -269,7 +269,7 @@ async def m008_create_admin_settings_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m009_create_tinyurl_table(db):
|
||||
async def m009_create_tinyurl_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS tiny_url (
|
||||
@@ -283,7 +283,7 @@ async def m009_create_tinyurl_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m010_create_installed_extensions_table(db):
|
||||
async def m010_create_installed_extensions_table(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS installed_extensions (
|
||||
@@ -300,7 +300,7 @@ async def m010_create_installed_extensions_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m011_optimize_balances_view(db):
|
||||
async def m011_optimize_balances_view(db: Connection):
|
||||
"""
|
||||
Make the calculation of the balance a single aggregation
|
||||
over the payments table instead of 2.
|
||||
@@ -317,7 +317,7 @@ async def m011_optimize_balances_view(db):
|
||||
)
|
||||
|
||||
|
||||
async def m012_add_currency_to_wallet(db):
|
||||
async def m012_add_currency_to_wallet(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN currency TEXT
|
||||
@@ -325,7 +325,7 @@ async def m012_add_currency_to_wallet(db):
|
||||
)
|
||||
|
||||
|
||||
async def m013_add_deleted_to_wallets(db):
|
||||
async def m013_add_deleted_to_wallets(db: Connection):
|
||||
"""
|
||||
Adds deleted column to wallets.
|
||||
"""
|
||||
@@ -337,7 +337,7 @@ async def m013_add_deleted_to_wallets(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m014_set_deleted_wallets(db):
|
||||
async def m014_set_deleted_wallets(db: Connection):
|
||||
"""
|
||||
Sets deleted column to wallets.
|
||||
"""
|
||||
@@ -381,7 +381,7 @@ async def m014_set_deleted_wallets(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m015_create_push_notification_subscriptions_table(db):
|
||||
async def m015_create_push_notification_subscriptions_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS webpush_subscriptions (
|
||||
@@ -396,7 +396,7 @@ async def m015_create_push_notification_subscriptions_table(db):
|
||||
)
|
||||
|
||||
|
||||
async def m016_add_username_column_to_accounts(db):
|
||||
async def m016_add_username_column_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds username column to accounts.
|
||||
"""
|
||||
@@ -407,7 +407,7 @@ async def m016_add_username_column_to_accounts(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
|
||||
"""
|
||||
Adds created_at and updated_at column to accounts and wallets.
|
||||
"""
|
||||
@@ -473,7 +473,7 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m018_balances_view_exclude_deleted(db):
|
||||
async def m018_balances_view_exclude_deleted(db: Connection):
|
||||
"""
|
||||
Make deleted wallets not show up in the balances view.
|
||||
"""
|
||||
@@ -493,7 +493,7 @@ async def m018_balances_view_exclude_deleted(db):
|
||||
)
|
||||
|
||||
|
||||
async def m019_balances_view_based_on_wallets(db):
|
||||
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.
|
||||
@@ -514,14 +514,14 @@ async def m019_balances_view_based_on_wallets(db):
|
||||
)
|
||||
|
||||
|
||||
async def m020_add_column_column_to_user_extensions(db):
|
||||
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):
|
||||
async def m021_add_success_failed_to_apipayments(db: Connection):
|
||||
"""
|
||||
Adds success and failed columns to apipayments.
|
||||
"""
|
||||
@@ -547,7 +547,7 @@ async def m021_add_success_failed_to_apipayments(db):
|
||||
)
|
||||
|
||||
|
||||
async def m022_add_pubkey_to_accounts(db):
|
||||
async def m022_add_pubkey_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds pubkey column to accounts.
|
||||
"""
|
||||
@@ -557,7 +557,7 @@ async def m022_add_pubkey_to_accounts(db):
|
||||
pass
|
||||
|
||||
|
||||
async def m023_add_column_column_to_apipayments(db):
|
||||
async def m023_add_column_column_to_apipayments(db: Connection):
|
||||
"""
|
||||
renames hash to payment_hash and drops unused index
|
||||
"""
|
||||
@@ -569,11 +569,11 @@ async def m023_add_column_column_to_apipayments(db):
|
||||
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
|
||||
|
||||
|
||||
async def m024_drop_pending(db):
|
||||
async def m024_drop_pending(db: Connection):
|
||||
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
|
||||
|
||||
|
||||
async def m025_refresh_view(db):
|
||||
async def m025_refresh_view(db: Connection):
|
||||
await db.execute("DROP VIEW balances")
|
||||
await db.execute(
|
||||
"""
|
||||
@@ -592,7 +592,7 @@ async def m025_refresh_view(db):
|
||||
)
|
||||
|
||||
|
||||
async def m026_update_payment_table(db):
|
||||
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")
|
||||
@@ -602,34 +602,46 @@ async def m026_update_payment_table(db):
|
||||
async def m027_update_apipayments_data(db: Connection):
|
||||
result = None
|
||||
try:
|
||||
result = await db.execute("SELECT * FROM apipayments")
|
||||
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")
|
||||
|
||||
result = await db.execute("SELECT * FROM apipayments")
|
||||
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}")
|
||||
|
||||
payments = result.mappings().all()
|
||||
for payment in payments:
|
||||
tag = None
|
||||
created_at = payment.get("time")
|
||||
if payment.get("extra"):
|
||||
extra = json.loads(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"),
|
||||
},
|
||||
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):
|
||||
@@ -666,7 +678,7 @@ async def m028_update_settings(db: Connection):
|
||||
await db.execute("drop table settings")
|
||||
|
||||
|
||||
async def m029_create_audit_table(db):
|
||||
async def m029_create_audit_table(db: Connection):
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
@@ -684,3 +696,18 @@ async def m029_create_audit_table(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -14,10 +14,15 @@ from .payments import (
|
||||
DecodePayment,
|
||||
PayInvoice,
|
||||
Payment,
|
||||
PaymentCountField,
|
||||
PaymentCountStat,
|
||||
PaymentDailyStats,
|
||||
PaymentExtra,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentsStatusCount,
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -25,17 +30,18 @@ from .users import (
|
||||
Account,
|
||||
AccountFilters,
|
||||
AccountOverview,
|
||||
CreateTopup,
|
||||
CreateUser,
|
||||
LoginUsernamePassword,
|
||||
LoginUsr,
|
||||
RegisterUser,
|
||||
ResetUserPassword,
|
||||
UpdateBalance,
|
||||
UpdateSuperuserPassword,
|
||||
UpdateUser,
|
||||
UpdateUserPassword,
|
||||
UpdateUserPubkey,
|
||||
User,
|
||||
UserAcls,
|
||||
UserExtra,
|
||||
)
|
||||
from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
|
||||
@@ -62,6 +68,11 @@ __all__ = [
|
||||
"DecodePayment",
|
||||
"PayInvoice",
|
||||
"Payment",
|
||||
"PaymentCountField",
|
||||
"PaymentCountStat",
|
||||
"PaymentDailyStats",
|
||||
"PaymentsStatusCount",
|
||||
"PaymentWalletStats",
|
||||
"PaymentExtra",
|
||||
"PaymentFilters",
|
||||
"PaymentHistoryPoint",
|
||||
@@ -73,12 +84,13 @@ __all__ = [
|
||||
"Account",
|
||||
"AccountFilters",
|
||||
"AccountOverview",
|
||||
"CreateTopup",
|
||||
"UserAcls",
|
||||
"CreateUser",
|
||||
"RegisterUser",
|
||||
"LoginUsernamePassword",
|
||||
"LoginUsr",
|
||||
"ResetUserPassword",
|
||||
"UpdateBalance",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
|
||||
+17
-18
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -10,16 +9,16 @@ from lnbits.settings import settings
|
||||
|
||||
|
||||
class AuditEntry(BaseModel):
|
||||
component: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
request_type: Optional[str] = None
|
||||
request_method: Optional[str] = None
|
||||
request_details: Optional[str] = None
|
||||
response_code: Optional[str] = None
|
||||
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: Optional[datetime] = None
|
||||
delete_at: datetime | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __init__(self, **data):
|
||||
@@ -42,17 +41,17 @@ class AuditFilters(FilterModel):
|
||||
"duration",
|
||||
]
|
||||
|
||||
ip_address: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
request_method: Optional[str] = None
|
||||
response_code: Optional[str] = None
|
||||
component: Optional[str] = None
|
||||
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
|
||||
field: str = ""
|
||||
total: float = 0
|
||||
|
||||
|
||||
class AuditStats(BaseModel):
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -16,6 +16,7 @@ from pydantic import BaseModel
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
file_hash,
|
||||
is_lnbits_version_ok,
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
@@ -28,21 +29,20 @@ class ExplicitRelease(BaseModel):
|
||||
archive: str
|
||||
hash: str
|
||||
dependencies: list[str] = []
|
||||
repo: Optional[str]
|
||||
icon: Optional[str]
|
||||
short_description: Optional[str]
|
||||
min_lnbits_version: Optional[str]
|
||||
html_url: Optional[str] # todo: release_url
|
||||
warning: Optional[str]
|
||||
info_notification: Optional[str]
|
||||
critical_notification: Optional[str]
|
||||
details_link: Optional[str]
|
||||
pay_link: Optional[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):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
|
||||
|
||||
class GitHubRelease(BaseModel):
|
||||
@@ -77,18 +77,17 @@ class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
tile: str = ""
|
||||
warning: Optional[str] = ""
|
||||
min_lnbits_version: Optional[str]
|
||||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
|
||||
def is_version_compatible(self):
|
||||
if not self.min_lnbits_version:
|
||||
return True
|
||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||
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
|
||||
) -> Optional[ExtensionConfig]:
|
||||
) -> ExtensionConfig | None:
|
||||
config_url = (
|
||||
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
|
||||
)
|
||||
@@ -98,28 +97,28 @@ class ExtensionConfig(BaseModel):
|
||||
|
||||
|
||||
class ReleasePaymentInfo(BaseModel):
|
||||
amount: Optional[int] = None
|
||||
pay_link: Optional[str] = None
|
||||
payment_hash: Optional[str] = None
|
||||
payment_request: Optional[str] = None
|
||||
amount: int | None = None
|
||||
pay_link: str | None = None
|
||||
payment_hash: str | None = None
|
||||
payment_request: str | None = None
|
||||
|
||||
|
||||
class PayToEnableInfo(BaseModel):
|
||||
amount: int
|
||||
amount: int = 0
|
||||
required: bool = False
|
||||
wallet: Optional[str] = None
|
||||
wallet: str | None = None
|
||||
|
||||
|
||||
class UserExtensionInfo(BaseModel):
|
||||
paid_to_enable: Optional[bool] = False
|
||||
payment_hash_to_enable: Optional[str] = None
|
||||
paid_to_enable: bool | None = False
|
||||
payment_hash_to_enable: str | None = None
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
user: str
|
||||
extension: str
|
||||
active: bool
|
||||
extra: Optional[UserExtensionInfo] = None
|
||||
extra: UserExtensionInfo | None = None
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
@@ -141,16 +140,14 @@ class UserExtension(BaseModel):
|
||||
class Extension(BaseModel):
|
||||
code: str
|
||||
is_valid: bool
|
||||
name: Optional[str] = None
|
||||
short_description: Optional[str] = None
|
||||
tile: Optional[str] = None
|
||||
upgrade_hash: Optional[str] = ""
|
||||
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:
|
||||
if settings.has_default_extension_path:
|
||||
return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
|
||||
return f"{self.code}-{self.upgrade_hash}"
|
||||
|
||||
if settings.has_default_extension_path:
|
||||
@@ -179,20 +176,21 @@ class ExtensionRelease(BaseModel):
|
||||
archive: str
|
||||
source_repo: str
|
||||
is_github_release: bool = False
|
||||
hash: Optional[str] = None
|
||||
min_lnbits_version: Optional[str] = None
|
||||
is_version_compatible: Optional[bool] = True
|
||||
html_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
repo: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
details_link: Optional[str] = None
|
||||
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: Optional[str] = None
|
||||
cost_sats: Optional[int] = None
|
||||
paid_sats: Optional[int] = 0
|
||||
payment_hash: Optional[str] = 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:
|
||||
@@ -210,8 +208,8 @@ class ExtensionRelease(BaseModel):
|
||||
self.cost_sats = payment_info.amount if payment_info else None
|
||||
|
||||
async def fetch_release_payment_info(
|
||||
self, amount: Optional[int] = None
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
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:
|
||||
@@ -251,6 +249,7 @@ class ExtensionRelease(BaseModel):
|
||||
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,
|
||||
@@ -282,7 +281,7 @@ class ExtensionRelease(BaseModel):
|
||||
return [GitHubRepoRelease.parse_obj(r) for r in releases]
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
|
||||
async def fetch_release_details(cls, details_link: str) -> dict | None:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -301,12 +300,12 @@ class ExtensionRelease(BaseModel):
|
||||
|
||||
|
||||
class ExtensionMeta(BaseModel):
|
||||
installed_release: Optional[ExtensionRelease] = None
|
||||
latest_release: Optional[ExtensionRelease] = None
|
||||
pay_to_enable: Optional[PayToEnableInfo] = None
|
||||
installed_release: ExtensionRelease | None = None
|
||||
latest_release: ExtensionRelease | None = None
|
||||
pay_to_enable: PayToEnableInfo | None = None
|
||||
payments: list[ReleasePaymentInfo] = []
|
||||
dependencies: list[str] = []
|
||||
archive: Optional[str] = None
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
|
||||
|
||||
@@ -314,11 +313,11 @@ class InstallableExtension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
active: Optional[bool] = False
|
||||
short_description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
active: bool | None = False
|
||||
short_description: str | None = None
|
||||
icon: str | None = None
|
||||
stars: int = 0
|
||||
meta: Optional[ExtensionMeta] = None
|
||||
meta: ExtensionMeta | None = None
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
@@ -342,12 +341,13 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
@property
|
||||
def ext_upgrade_dir(self) -> Path:
|
||||
return Path(
|
||||
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
|
||||
)
|
||||
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
|
||||
@@ -405,9 +405,7 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path(settings.lnbits_extensions_path, "upgrades").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
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)
|
||||
@@ -454,7 +452,7 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
shutil.rmtree(self.ext_upgrade_dir, True)
|
||||
|
||||
def check_latest_version(self, release: Optional[ExtensionRelease]):
|
||||
def check_latest_version(self, release: ExtensionRelease | None):
|
||||
if not release:
|
||||
return
|
||||
if not self.meta or not self.meta.latest_release:
|
||||
@@ -467,9 +465,7 @@ class InstallableExtension(BaseModel):
|
||||
):
|
||||
self.meta.latest_release = release
|
||||
|
||||
def find_existing_payment(
|
||||
self, pay_link: Optional[str]
|
||||
) -> Optional[ReleasePaymentInfo]:
|
||||
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(
|
||||
@@ -509,7 +505,7 @@ class InstallableExtension(BaseModel):
|
||||
@classmethod
|
||||
async def from_github_release(
|
||||
cls, github_release: GitHubRelease
|
||||
) -> Optional[InstallableExtension]:
|
||||
) -> InstallableExtension | None:
|
||||
try:
|
||||
repo, latest_release, config = await cls.fetch_github_repo_info(
|
||||
github_release.organisation, github_release.repository
|
||||
@@ -548,7 +544,7 @@ class InstallableExtension(BaseModel):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]:
|
||||
def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
|
||||
try:
|
||||
conf_path = Path(
|
||||
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||
@@ -573,6 +569,7 @@ class InstallableExtension(BaseModel):
|
||||
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"),
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -587,7 +584,6 @@ class InstallableExtension(BaseModel):
|
||||
cls,
|
||||
) -> list[InstallableExtension]:
|
||||
extension_list: list[InstallableExtension] = []
|
||||
extension_id_list: list[str] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
try:
|
||||
@@ -608,7 +604,6 @@ class InstallableExtension(BaseModel):
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
extension_id_list += [ext.id]
|
||||
|
||||
for e in manifest.extensions:
|
||||
release = ExtensionRelease.from_explicit_release(url, e)
|
||||
@@ -624,10 +619,10 @@ class InstallableExtension(BaseModel):
|
||||
meta.featured = ext.id in manifest.featured
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
extension_id_list += [e.id]
|
||||
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
|
||||
@@ -660,7 +655,7 @@ class InstallableExtension(BaseModel):
|
||||
@classmethod
|
||||
async def get_extension_release(
|
||||
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||
) -> Optional[ExtensionRelease]:
|
||||
) -> ExtensionRelease | None:
|
||||
all_releases: list[ExtensionRelease] = (
|
||||
await InstallableExtension.get_extension_releases(ext_id)
|
||||
)
|
||||
@@ -711,8 +706,8 @@ class CreateExtension(BaseModel):
|
||||
archive: str
|
||||
source_repo: str
|
||||
version: str
|
||||
cost_sats: Optional[int] = 0
|
||||
payment_hash: Optional[str] = None
|
||||
cost_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
|
||||
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
@@ -721,7 +716,7 @@ class ExtensionDetailsRequest(BaseModel):
|
||||
version: str
|
||||
|
||||
|
||||
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
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}"
|
||||
@@ -733,7 +728,7 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||
return resp.json()
|
||||
|
||||
|
||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||
def icon_to_github_url(source_repo: str, path: str | None) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
_, _, *rest = path.split("/")
|
||||
|
||||
@@ -25,12 +25,12 @@ class Callback(BaseModel):
|
||||
|
||||
|
||||
class BalanceDelta(BaseModel):
|
||||
lnbits_balance_msats: int
|
||||
node_balance_msats: int
|
||||
lnbits_balance_sats: int
|
||||
node_balance_sats: int
|
||||
|
||||
@property
|
||||
def delta_msats(self):
|
||||
return self.node_balance_msats - self.lnbits_balance_msats
|
||||
def delta_sats(self) -> int:
|
||||
return int(self.lnbits_balance_sats - self.node_balance_sats)
|
||||
|
||||
|
||||
class SimpleStatus(BaseModel):
|
||||
@@ -38,6 +38,11 @@ class SimpleStatus(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class SimpleItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class DbVersion(BaseModel):
|
||||
db: str
|
||||
version: int
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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}).
|
||||
""",
|
||||
}
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, Field, validator
|
||||
@@ -28,16 +28,16 @@ class PaymentState(str, Enum):
|
||||
|
||||
|
||||
class PaymentExtra(BaseModel):
|
||||
comment: Optional[str] = None
|
||||
success_action: Optional[str] = None
|
||||
lnurl_response: Optional[str] = None
|
||||
comment: str | None = None
|
||||
success_action: str | None = None
|
||||
lnurl_response: str | None = None
|
||||
|
||||
|
||||
class PayInvoice(BaseModel):
|
||||
payment_request: str
|
||||
description: Optional[str] = None
|
||||
max_sat: Optional[int] = None
|
||||
extra: Optional[dict] = {}
|
||||
description: str | None = None
|
||||
max_sat: int | None = None
|
||||
extra: dict | None = {}
|
||||
|
||||
|
||||
class CreatePayment(BaseModel):
|
||||
@@ -46,10 +46,10 @@ class CreatePayment(BaseModel):
|
||||
bolt11: str
|
||||
amount_msat: int
|
||||
memo: str
|
||||
extra: Optional[dict] = {}
|
||||
preimage: Optional[str] = None
|
||||
expiry: Optional[datetime] = None
|
||||
webhook: Optional[str] = None
|
||||
extra: dict | None = {}
|
||||
preimage: str | None = None
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
|
||||
|
||||
@@ -61,13 +61,13 @@ class Payment(BaseModel):
|
||||
fee: int
|
||||
bolt11: str
|
||||
status: str = PaymentState.PENDING
|
||||
memo: Optional[str] = None
|
||||
expiry: Optional[datetime] = None
|
||||
webhook: Optional[str] = None
|
||||
webhook_status: Optional[int] = None
|
||||
preimage: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
extension: Optional[str] = None
|
||||
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))
|
||||
@@ -125,21 +125,60 @@ class Payment(BaseModel):
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
__search_fields__ = ["memo", "amount"]
|
||||
__search_fields__ = ["memo", "amount", "wallet_id", "tag", "status", "time"]
|
||||
|
||||
checking_id: str
|
||||
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
checking_id: str | None
|
||||
amount: int
|
||||
fee: int
|
||||
memo: Optional[str]
|
||||
memo: str | None
|
||||
time: datetime
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[datetime]
|
||||
extra: dict = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
webhook_status: Optional[int]
|
||||
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):
|
||||
@@ -151,7 +190,7 @@ class PaymentHistoryPoint(BaseModel):
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
filter_fields: Optional[list[str]] = []
|
||||
filter_fields: list[str] | None = []
|
||||
|
||||
|
||||
class CreateInvoice(BaseModel):
|
||||
@@ -159,14 +198,14 @@ class CreateInvoice(BaseModel):
|
||||
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
|
||||
extra: Optional[dict] = None
|
||||
webhook: Optional[str] = None
|
||||
bolt11: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
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
|
||||
@@ -174,3 +213,10 @@ class CreateInvoice(BaseModel):
|
||||
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
|
||||
|
||||
+129
-40
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
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
|
||||
@@ -16,25 +16,86 @@ from .wallets import Wallet
|
||||
|
||||
|
||||
class UserExtra(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
|
||||
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: Optional[str] = "lnbits" # auth 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: Optional[str] = None
|
||||
password_hash: Optional[str] = None
|
||||
pubkey: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
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))
|
||||
|
||||
@@ -72,10 +133,10 @@ class Account(BaseModel):
|
||||
|
||||
|
||||
class AccountOverview(Account):
|
||||
transaction_count: Optional[int] = 0
|
||||
wallet_count: Optional[int] = 0
|
||||
balance_msat: Optional[int] = 0
|
||||
last_payment: Optional[datetime] = None
|
||||
transaction_count: int | None = 0
|
||||
wallet_count: int | None = 0
|
||||
balance_msat: int | None = 0
|
||||
last_payment: datetime | None = None
|
||||
|
||||
|
||||
class AccountFilters(FilterModel):
|
||||
@@ -89,20 +150,20 @@ class AccountFilters(FilterModel):
|
||||
"last_payment",
|
||||
]
|
||||
|
||||
email: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
pubkey: Optional[str] = None
|
||||
wallet_id: Optional[str] = None
|
||||
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: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
pubkey: Optional[str] = None
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
pubkey: str | None = None
|
||||
extensions: list[str] = []
|
||||
wallets: list[Wallet] = []
|
||||
admin: bool = False
|
||||
@@ -114,7 +175,7 @@ class User(BaseModel):
|
||||
def wallet_ids(self) -> list[str]:
|
||||
return [wallet.id for wallet in self.wallets]
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
|
||||
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
|
||||
|
||||
@@ -130,33 +191,33 @@ class User(BaseModel):
|
||||
|
||||
|
||||
class RegisterUser(BaseModel):
|
||||
email: Optional[str] = Query(default=None)
|
||||
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: Optional[str] = Query(default=None)
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: Optional[str] = Query(default=None, min_length=2, max_length=20)
|
||||
password: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||
password_repeat: Optional[str] = Query(default=None, min_length=8, max_length=50)
|
||||
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: Optional[list[str]] = None
|
||||
extra: Optional[UserExtra] = None
|
||||
extensions: list[str] | None = None
|
||||
extra: UserExtra | None = None
|
||||
|
||||
|
||||
class UpdateUser(BaseModel):
|
||||
user_id: str
|
||||
email: Optional[str] = Query(default=None)
|
||||
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
|
||||
extra: Optional[UserExtra] = None
|
||||
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: Optional[str] = None
|
||||
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)
|
||||
@@ -190,11 +251,39 @@ class LoginUsernamePassword(BaseModel):
|
||||
|
||||
class AccessTokenPayload(BaseModel):
|
||||
sub: str
|
||||
usr: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
auth_time: Optional[int] = 0
|
||||
usr: str | None = None
|
||||
email: str | None = None
|
||||
auth_time: int | None = 0
|
||||
api_token_id: str | None = None
|
||||
|
||||
|
||||
class CreateTopup(BaseModel):
|
||||
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
|
||||
|
||||
@@ -5,7 +5,6 @@ import hmac
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -23,6 +22,11 @@ class BaseWallet(BaseModel):
|
||||
balance_msat: int
|
||||
|
||||
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
id: str
|
||||
user: str
|
||||
@@ -32,8 +36,9 @@ class Wallet(BaseModel):
|
||||
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: Optional[str] = None
|
||||
currency: str | None = None
|
||||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
@@ -61,7 +66,7 @@ class Wallet(BaseModel):
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: Optional[str] = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
|
||||
@@ -3,6 +3,7 @@ from .funding_source import (
|
||||
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,
|
||||
@@ -11,7 +12,6 @@ from .payments import (
|
||||
fee_reserve,
|
||||
fee_reserve_total,
|
||||
pay_invoice,
|
||||
send_payment_notification,
|
||||
service_fee,
|
||||
update_pending_payments,
|
||||
update_wallet_balance,
|
||||
@@ -37,6 +37,9 @@ __all__ = [
|
||||
# lnurl
|
||||
"redeem_lnurl_withdraw",
|
||||
"perform_lnurlauth",
|
||||
# notifications
|
||||
"enqueue_notification",
|
||||
"send_payment_notification",
|
||||
# payments
|
||||
"calculate_fiat_amounts",
|
||||
"check_transaction_status",
|
||||
@@ -45,7 +48,6 @@ __all__ = [
|
||||
"fee_reserve",
|
||||
"fee_reserve_total",
|
||||
"pay_invoice",
|
||||
"send_payment_notification",
|
||||
"service_fee",
|
||||
"update_pending_payments",
|
||||
"update_wallet_balance",
|
||||
|
||||
@@ -23,18 +23,23 @@ from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
|
||||
|
||||
async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
ext_id = ext_info.id
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
|
||||
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 = ext_info.meta or ExtensionMeta()
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
|
||||
await ext_info.download_archive()
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_id)
|
||||
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
|
||||
@@ -44,9 +49,12 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
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_id)
|
||||
await stop_extension_background_work(ext_info.id)
|
||||
|
||||
await start_extension_background_work(ext_info.id)
|
||||
|
||||
return extension
|
||||
|
||||
@@ -75,7 +83,7 @@ async def deactivate_extension(ext_id: str):
|
||||
async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
"""
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extensions SHOULD expose a `api_stop()` function.
|
||||
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)
|
||||
@@ -84,11 +92,10 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
logger.info(f"Stopping background work for extension '{ext.module_name}'.")
|
||||
old_module = importlib.import_module(ext.module_name)
|
||||
|
||||
# Extensions must expose an `{ext_id}_stop()` function at the module level
|
||||
# The `api_stop()` function is for backwards compatibility (will be deprecated)
|
||||
stop_fns = [f"{ext_id}_stop", "api_stop"]
|
||||
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
|
||||
assert stop_fn_name, f"No stop function found for '{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:
|
||||
@@ -96,7 +103,6 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
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}'.")
|
||||
@@ -106,6 +112,38 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
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]:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -18,6 +22,62 @@ async def get_balance_delta() -> BalanceDelta:
|
||||
status = await funding_source.status()
|
||||
lnbits_balance = await get_total_balance()
|
||||
return BalanceDelta(
|
||||
lnbits_balance_msats=lnbits_balance,
|
||||
node_balance_msats=status.balance_msat,
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
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
|
||||
@@ -37,6 +37,7 @@ async def redeem_lnurl_withdraw(
|
||||
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()
|
||||
|
||||
@@ -72,6 +73,7 @@ async def redeem_lnurl_withdraw(
|
||||
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
|
||||
@@ -135,6 +137,7 @@ async def perform_lnurlauth(
|
||||
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={
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
@@ -0,0 +1,314 @@
|
||||
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}"
|
||||
)
|
||||
@@ -1,16 +1,22 @@
|
||||
import json
|
||||
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.types import Bolt11
|
||||
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.db import Connection
|
||||
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 (
|
||||
@@ -36,7 +42,7 @@ from ..models import (
|
||||
PaymentState,
|
||||
Wallet,
|
||||
)
|
||||
from .websockets import websocket_manager
|
||||
from .notifications import send_payment_notification
|
||||
|
||||
|
||||
async def pay_invoice(
|
||||
@@ -49,14 +55,16 @@ async def pay_invoice(
|
||||
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 conn:
|
||||
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, conn)
|
||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
|
||||
|
||||
if await is_internal_status_success(invoice.payment_hash, 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)
|
||||
@@ -71,8 +79,10 @@ async def pay_invoice(
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet, create_payment_model, conn)
|
||||
await _credit_service_fee_wallet(payment, conn)
|
||||
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
|
||||
|
||||
@@ -107,6 +117,12 @@ async def create_invoice(
|
||||
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
|
||||
):
|
||||
@@ -162,13 +178,20 @@ async def update_pending_payments(wallet_id: str):
|
||||
exclude_uncheckable=True,
|
||||
)
|
||||
for payment in pending_payments:
|
||||
status = await payment.check_status()
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment)
|
||||
elif status.success:
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
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:
|
||||
@@ -195,12 +218,59 @@ def service_fee(amount_msat: int, internal: bool = False) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
async with db.connect() as conn:
|
||||
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,
|
||||
wallet_id=wallet.id,
|
||||
amount=amount,
|
||||
memo="Admin top up",
|
||||
memo="Admin credit",
|
||||
internal=True,
|
||||
conn=conn,
|
||||
)
|
||||
@@ -212,27 +282,6 @@ async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
|
||||
|
||||
async def send_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
|
||||
await websocket_manager.send_data(
|
||||
json.dumps(
|
||||
{
|
||||
"wallet_balance": wallet.balance,
|
||||
# use pydantic json serialization to get the correct datetime format
|
||||
"payment": json.loads(payment.json()),
|
||||
},
|
||||
),
|
||||
wallet.inkey,
|
||||
)
|
||||
await websocket_manager.send_data(
|
||||
json.dumps({"pending": payment.pending}), payment.payment_hash
|
||||
)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
|
||||
):
|
||||
@@ -305,17 +354,22 @@ async def calculate_fiat_amounts(
|
||||
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:
|
||||
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
|
||||
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=}"
|
||||
@@ -339,6 +393,50 @@ async def check_transaction_status(
|
||||
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:
|
||||
@@ -442,13 +540,19 @@ async def _pay_external_invoice(
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
service_fee_msat = service_fee(amount_msat, internal=False)
|
||||
|
||||
funding_source = get_funding_source()
|
||||
|
||||
logger.debug(f"fundingsource: sending payment {checking_id}")
|
||||
payment_response: PaymentResponse = await funding_source.pay_invoice(
|
||||
create_payment_model.bolt11, fee_reserve_msat
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
|
||||
|
||||
# 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:"
|
||||
@@ -484,9 +588,22 @@ async def _pay_external_invoice(
|
||||
"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:
|
||||
@@ -546,8 +663,14 @@ def _validate_payment_request(
|
||||
if not invoice.amount_msat or not invoice.amount_msat > 0:
|
||||
raise PaymentError("Amountless invoices not supported.", status="failed")
|
||||
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise PaymentError("Amount in invoice is too high.", 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
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ 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,
|
||||
)
|
||||
@@ -36,15 +38,21 @@ async def check_webpush_settings():
|
||||
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):
|
||||
for key, value in sets_dict.items():
|
||||
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}, value: {value}")
|
||||
logger.warning(f"Failed overriding setting: {key}.")
|
||||
if "super_user" in sets_dict:
|
||||
settings.super_user = sets_dict["super_user"]
|
||||
|
||||
+116
-131
@@ -1,80 +1,92 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
import traceback
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_audit_entry,
|
||||
get_wallet,
|
||||
get_webpush_subscriptions_for_user,
|
||||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.models import AuditEntry, Payment
|
||||
from lnbits.core.services import (
|
||||
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,
|
||||
get_balance_delta,
|
||||
send_payment_notification,
|
||||
switch_to_voidwallet,
|
||||
)
|
||||
from lnbits.settings import get_funding_source, settings
|
||||
from lnbits.tasks import send_push_notification
|
||||
from lnbits.core.services.notifications import (
|
||||
enqueue_notification,
|
||||
process_next_notification,
|
||||
send_payment_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
|
||||
|
||||
api_invoice_listeners: Dict[str, asyncio.Queue] = {}
|
||||
audit_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
|
||||
async def killswitch_task():
|
||||
"""
|
||||
killswitch will check lnbits-status repository for a signal from
|
||||
LNbits and will switch to VoidWallet if the killswitch is triggered.
|
||||
"""
|
||||
async def run_by_the_minute_tasks():
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
funding_source = get_funding_source()
|
||||
if (
|
||||
settings.lnbits_killswitch
|
||||
and funding_source.__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.RequestError, httpx.HTTPStatusError):
|
||||
logger.error(
|
||||
"Cannot fetch lnbits status manifest."
|
||||
f" {settings.lnbits_status_manifest}"
|
||||
)
|
||||
await asyncio.sleep(settings.lnbits_killswitch_interval * 60)
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
|
||||
|
||||
async def watchdog_task():
|
||||
"""
|
||||
Registers a watchdog which will check lnbits balance and nodebalance
|
||||
and will switch to VoidWallet if the watchdog delta is reached.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
funding_source = get_funding_source()
|
||||
if (
|
||||
settings.lnbits_watchdog
|
||||
and funding_source.__class__.__name__ != "VoidWallet"
|
||||
):
|
||||
if settings.notification_balance_delta_changed:
|
||||
try:
|
||||
balance = await get_balance_delta()
|
||||
delta = balance.delta_msats
|
||||
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)
|
||||
# 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
@@ -84,82 +96,10 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# dispatch api_invoice_listeners
|
||||
await dispatch_api_invoice_listeners(payment)
|
||||
# payment notification
|
||||
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 push notification
|
||||
await send_payment_push_notification(payment)
|
||||
|
||||
|
||||
async def dispatch_api_invoice_listeners(payment: Payment):
|
||||
"""
|
||||
Emits events to invoice listener subscribed from the API.
|
||||
"""
|
||||
for chan_name, send_channel in api_invoice_listeners.items():
|
||||
try:
|
||||
logger.debug(f"api invoice listener: sending paid event to {chan_name}")
|
||||
send_channel.put_nowait(payment)
|
||||
except asyncio.QueueFull:
|
||||
logger.error(
|
||||
f"api invoice listener: QueueFull, removing {send_channel}:{chan_name}"
|
||||
)
|
||||
api_invoice_listeners.pop(chan_name)
|
||||
|
||||
|
||||
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:
|
||||
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_push_notification(payment: Payment):
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
|
||||
if wallet:
|
||||
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 wait_for_audit_data():
|
||||
@@ -176,6 +116,16 @@ async def wait_for_audit_data():
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages():
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await process_next_notification()
|
||||
except Exception as ex:
|
||||
logger.log("error", ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data():
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
@@ -188,3 +138,38 @@ async def purge_audit_data():
|
||||
|
||||
# clean every hour
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _create_unique_task(name: str, func: Callable):
|
||||
async def _to_coro(func: Callable[[], Coroutine]) -> Coroutine:
|
||||
return await func()
|
||||
|
||||
try:
|
||||
create_unique_task(name, _to_coro(func))
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {name} task", e)
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -15,11 +15,8 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Enable audit</q-item-label>
|
||||
<q-item-label caption
|
||||
>Record HTTP requests according with the specified
|
||||
filters</q-item-label
|
||||
>
|
||||
<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>
|
||||
@@ -48,14 +45,16 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record Request Body</q-item-label>
|
||||
<q-item-label caption
|
||||
>Warning:
|
||||
<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>confidential data (like passwords) will be logged</li>
|
||||
<li>the request body can have large size.</li>
|
||||
<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>
|
||||
Use it with caution.
|
||||
<br />
|
||||
<span v-text="$t('audit_record_use')"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -72,8 +71,12 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record IP Address</q-item-label>
|
||||
<q-item-label caption>Save the client IP address.</q-item-label>
|
||||
<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>
|
||||
@@ -89,8 +92,12 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record Path Parameters</q-item-label>
|
||||
<q-item-label caption>Recommended. </q-item-label>
|
||||
<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>
|
||||
@@ -106,8 +113,12 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Record Query Parameters</q-item-label>
|
||||
<q-item-label caption>Recommended.</q-item-label>
|
||||
<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>
|
||||
@@ -115,25 +126,26 @@
|
||||
<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>Include HTTP Methods</p>
|
||||
<p><span v-text="$t('audit_http_methods')"></span></p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_audit_http_methods"
|
||||
multiple
|
||||
hint="List of HTTP methods to be included. Empty lists means all."
|
||||
label="HTTP Methods"
|
||||
: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>Include HTTP Response Codes</p>
|
||||
<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="HTTP Response code (regex)"
|
||||
hint="List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*"
|
||||
:label="$t('audit_resp_codes_label')"
|
||||
:hint="$t('audit_resp_codes_hint')"
|
||||
>
|
||||
<q-btn @click="addIncludeResponseCode" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
@@ -154,14 +166,15 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-12 q-pr-sm">
|
||||
<p>Include Paths</p>
|
||||
<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="HTTP Path (regex)"
|
||||
hint="List of paths to be included (regex match). Empty list means all."
|
||||
:label="$t('audit_paths_label')"
|
||||
:hint="$t('audit_paths_hint')"
|
||||
>
|
||||
<q-btn @click="addIncludePath" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
@@ -180,14 +193,17 @@
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<p>Exclude Paths</p>
|
||||
<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="HTTP Path (regex)"
|
||||
hint="List of paths to be excluded (regex match). Empty list means none."
|
||||
:label="$t('audit_paths_exclude_label')"
|
||||
:hint="$t('audit_paths_exclude_hint')"
|
||||
>
|
||||
<q-btn @click="addExcludePath" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<q-tab-panel name="exchange_providers">
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-sm-12">
|
||||
<div class="q-pa-sm">
|
||||
<canvas
|
||||
style="
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 350px;
|
||||
max-height: 50vh;
|
||||
"
|
||||
ref="exchangeRatesChart"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
|
||||
type="number"
|
||||
label="Refresh Interval (seconds)"
|
||||
hint="How often should the exchange rates be fetched. Set to zero to disable."
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_history_size"
|
||||
type="number"
|
||||
label="History Size"
|
||||
hint="How many data points should be kept in memory."
|
||||
>
|
||||
</q-input>
|
||||
<ul>
|
||||
<li>
|
||||
<code>Refresh Interval</code> and <code> History Size </code>are for
|
||||
historical purposes only.
|
||||
</li>
|
||||
<li>These two settings do not affect the live price computation.</li>
|
||||
<li>
|
||||
Chart currency:
|
||||
<strong
|
||||
><span
|
||||
v-text="formData.lnbits_default_accounting_currency || 'USD'"
|
||||
></span
|
||||
></strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="addExchangeProvider()"
|
||||
label="Add Exchange Provider"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@click="getDefaultSetting('lnbits_exchange_rate_providers')"
|
||||
flat
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-table
|
||||
row-key="name"
|
||||
:rows="formData.lnbits_exchange_rate_providers"
|
||||
:columns="exchangesTable.columns"
|
||||
v-model:pagination="exchangesTable.pagination"
|
||||
>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
<span v-text="col.label"></span>
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="removeExchangeProvider(props.row)"
|
||||
round
|
||||
icon="delete"
|
||||
size="sm"
|
||||
color="negative"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.name"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td full-width>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.api_url"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input
|
||||
></q-td>
|
||||
<q-td>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
v-model="props.row.path"
|
||||
@update:model-value="touchSettings()"
|
||||
type="text"
|
||||
>
|
||||
</q-input>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="props.row.exclude_to"
|
||||
@update:model-value="touchSettings()"
|
||||
multiple
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
@click="showTickerConversionDialog(props.row)"
|
||||
round
|
||||
icon="add"
|
||||
size="sm"
|
||||
color="gray"
|
||||
class="q-ml-xs"
|
||||
>
|
||||
</q-btn>
|
||||
<q-chip
|
||||
v-for="ticker, index in props.row.ticker_conversion"
|
||||
:key="ticker"
|
||||
removable
|
||||
dense
|
||||
filled
|
||||
@remove="removeExchangeTickerConversion(props.row, ticker)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
:label="ticker"
|
||||
class="ellipsis"
|
||||
>
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
<ul>
|
||||
<li>
|
||||
<code>API URL</code> and <code>JSON Path</code> fields can use the
|
||||
<code>{to}</code> and <code>{TO}</code> placeholders for the code of the
|
||||
currency
|
||||
</li>
|
||||
<li>
|
||||
<code>{TO}</code> is the uppercase code and <code>{to}</code> is the
|
||||
lowercase code
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<q-separator class="q-ma-md"></q-separator>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_exchange_rate_cache_seconds"
|
||||
type="number"
|
||||
label="Exchange rate cache (seconds)"
|
||||
hint="For how many seconds should the exchange rate be cached."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-md-8 col-sm-12"></div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
@@ -1,17 +1,22 @@
|
||||
<q-tab-panel name="extensions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<h6 class="q-my-none">Extensions</h6>
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('extensions')"></span>
|
||||
</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<p>Extension Sources</p>
|
||||
<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="Source URL (only use the official LNbits extension source, and sources you can trust)"
|
||||
hint="Repositories from where the extensions can be downloaded"
|
||||
:label="$t('ext_sources_label')"
|
||||
:hint="$t('ext_sources_hint')"
|
||||
>
|
||||
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
@@ -30,34 +35,44 @@
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<p>
|
||||
<span v-text="$t('admin_extensions')"></span>
|
||||
</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:label="$t('admin_extensions_label')"
|
||||
:hint="$t('admin_extensions_hint')"
|
||||
:options="g.extensions"
|
||||
></q-select>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<p>User Default Extensions</p>
|
||||
<p>
|
||||
<span v-text="$t('user_default_extensions')"></span>
|
||||
</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_user_default_extensions"
|
||||
multiple
|
||||
hint="Extensions that will be enabled by default for the users."
|
||||
label="User extensions"
|
||||
: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>Miscellaneous</p>
|
||||
<p>
|
||||
<span v-text="$t('miscellaneous')"></span>
|
||||
</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Extensions</q-item-label>
|
||||
<q-item-label caption>Disables all extensions</q-item-label>
|
||||
<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
|
||||
@@ -71,10 +86,12 @@
|
||||
</q-item>
|
||||
<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-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
|
||||
|
||||
@@ -1,88 +1,104 @@
|
||||
<q-tab-panel name="funding">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Wallets Management</h6>
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('wallets_management')"></span>
|
||||
</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Funding Source Info</p>
|
||||
<p>
|
||||
<span v-text="$t('funding_source_info')"></span>
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
v-text="'Funding Source: '+ settings.lnbits_backend_wallet_class"
|
||||
v-text="$t('funding_source', {wallet_class: settings.lnbits_backend_wallet_class})"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Node Balance: ' + (auditData.node_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
v-text="$t('node_balance', {balance: (auditData.node_balance_sats || 0).toLocaleString()})"
|
||||
></li>
|
||||
<li
|
||||
v-text="'LNbits Balance: ' + (auditData.lnbits_balance_msats /
|
||||
1000).toLocaleString() + ' sats'"
|
||||
v-text="$t('lnbits_balance', {balance: (auditData.lnbits_balance_sats || 0).toLocaleString()})"
|
||||
></li>
|
||||
<li
|
||||
v-text="'Reserve Percent: ' + (auditData.node_balance_msats /
|
||||
auditData.lnbits_balance_msats * 100).toFixed(2) + ' %'"
|
||||
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>
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col">
|
||||
{% if LNBITS_NODE_UI_AVAILABLE %}
|
||||
<p>Node Management</p>
|
||||
<p><span v-text="$t('node_management')"></span></p>
|
||||
<q-toggle
|
||||
label="Node UI"
|
||||
:label="$t('toggle_node_ui')"
|
||||
v-model="formData.lnbits_node_ui"
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Public node UI"
|
||||
:label="$t('toggle_public_node_ui')"
|
||||
v-model="formData.lnbits_public_node_ui"
|
||||
></q-toggle>
|
||||
<br />
|
||||
<q-toggle
|
||||
v-if="formData.lnbits_node_ui"
|
||||
label="Transactions Tab (Disable on large CLN nodes)"
|
||||
:label="$t('toggle_transactions_node_ui')"
|
||||
v-model="formData.lnbits_node_ui_transactions"
|
||||
></q-toggle>
|
||||
{% else %}
|
||||
<p>Node Management not supported by active funding source</p>
|
||||
<p><span v-text="$t('node_management_not_supported')"></span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Invoice Expiry</p>
|
||||
<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>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lightning_invoice_expiry"
|
||||
type="number"
|
||||
label="Invoice expiry (seconds)"
|
||||
:label="$t('invoice_expiry_label')"
|
||||
mask="#######"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
@@ -91,6 +107,90 @@
|
||||
: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,140 +1,481 @@
|
||||
<q-tab-panel name="notifications">
|
||||
<q-card-section class="q-pa-none">
|
||||
<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>
|
||||
<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="formData.lnbits_status_manifest"
|
||||
type="text"
|
||||
:label="$t('notification_source_label')"
|
||||
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>
|
||||
<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-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>
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
<q-tab-panel name="security">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<h6 class="q-my-none"><span v-text="$t('server_management')"></span></h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p>Base URL</p>
|
||||
<p><span v-text="$t('base_url')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="formData.lnbits_baseurl"
|
||||
label="Static/Base url for the server"
|
||||
:label="$t('base_url_label')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<h6 class="q-my-none q-mb-sm">Authentication</h6>
|
||||
<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">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.auth_token_expire_minutes"
|
||||
type="number"
|
||||
label="Token expire minutes"
|
||||
hint="Time in minutes until the token expires"
|
||||
:label="$t('auth_token_expiry_label')"
|
||||
:hint="$t('auth_token_expiry_hint')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -29,8 +31,8 @@
|
||||
filled
|
||||
v-model="formData.auth_allowed_methods"
|
||||
multiple
|
||||
hint="Allowed authorization methods"
|
||||
label="Select authorization methods"
|
||||
:hint="$t('auth_allowed_methods_hint')"
|
||||
:label="$t('auth_allowed_methods_label')"
|
||||
:options="formData.auth_all_methods"
|
||||
></q-select>
|
||||
</div>
|
||||
@@ -50,8 +52,8 @@
|
||||
v-model="nostrAcceptedUrl"
|
||||
@keydown.enter="addNostrUrl"
|
||||
type="text"
|
||||
label="Nostr Request URL"
|
||||
hint="Absolute URL that the clients will use to login."
|
||||
:label="$t('auth_nostr_label')"
|
||||
:hint="$t('auth_nostr_hint')"
|
||||
>
|
||||
<q-btn @click="addNostrUrl" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
@@ -81,8 +83,8 @@
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.google_client_id"
|
||||
label="Google Client ID"
|
||||
hint="Make sure thant the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token"
|
||||
:label="$t('auth_google_ci_label')"
|
||||
:hint="$t('auth_google_ci_hint')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -91,7 +93,7 @@
|
||||
filled
|
||||
v-model="formData.google_client_secret"
|
||||
type="password"
|
||||
label="Google Client Secret"
|
||||
:label="$t('auth_google_cs_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -108,8 +110,8 @@
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.github_client_id"
|
||||
label="GitHub Client ID"
|
||||
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/github/token"
|
||||
:label="$t('auth_gh_client_id_label')"
|
||||
:hint="$t('auth_gh_client_id_hint')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -118,7 +120,7 @@
|
||||
filled
|
||||
v-model="formData.github_client_secret"
|
||||
type="password"
|
||||
label="GitHub Client Secret"
|
||||
:label="$t('auth_gh_client_secret_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -135,7 +137,7 @@
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_discovery_url"
|
||||
label="Keycloak Discovey URL"
|
||||
:label="$t('auth_keycloak_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -143,8 +145,8 @@
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.keycloak_client_id"
|
||||
label="Keycloak Client ID"
|
||||
hint="Make sure thant the authorization callback URL is set to https://{domain}/api/v1/auth/keycloak/token"
|
||||
:label="$t('auth_keycloak_ci_label')"
|
||||
:hint="$t('auth_keycloak_ci_hint')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -153,7 +155,7 @@
|
||||
filled
|
||||
v-model="formData.keycloak_client_secret"
|
||||
type="password"
|
||||
label="Keycloak Client Secret"
|
||||
:label="$t('auth_keycloak_cs_label')"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
@@ -281,31 +283,37 @@
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-12">
|
||||
<p v-text="$t('wallet_limiter')"></p>
|
||||
<p v-text="$t('callback_url_rules')"></p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-4">
|
||||
<div class="col-12">
|
||||
<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-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>
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<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
|
||||
:rows="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"
|
||||
v-text="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"
|
||||
v-text="formatDate(props.row.date)"
|
||||
>
|
||||
</q-td>
|
||||
<q-td key="message" :props="props"
|
||||
><span v-text="props.row.message"></span
|
||||
><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>
|
||||
@@ -1,79 +1,144 @@
|
||||
<q-tab-panel name="server">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<h6 class="q-my-none">Currency Settings</h6>
|
||||
<h6 class="q-my-none"><span v-text="$t('currency_settings')"></span></h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed currencies</p>
|
||||
<p><span v-text="$t('allowed_currencies')"></span></p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_allowed_currencies"
|
||||
multiple
|
||||
hint="Limit the number of available fiat currencies"
|
||||
label="Allowed currencies"
|
||||
:hint="$t('allowed_currencies_hint')"
|
||||
:label="$t('allowed_currencies')"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Default Accounting Currency</p>
|
||||
<p><span v-text="$t('default_account_currency')"></span></p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_default_accounting_currency"
|
||||
clearable
|
||||
hint="Default currency for accounting"
|
||||
label="Currency"
|
||||
:hint="$t('default_account_currency_hint')"
|
||||
:label="$t('currency')"
|
||||
:options="formData.lnbits_allowed_currencies?.length ? formData.lnbits_allowed_currencies : {{ currencies }}"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<h6 class="q-my-none">Service Fee</h6>
|
||||
<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>Service Fee</p>
|
||||
<p><span v-text="$t('service_fee')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
:label="$t('service_fee_label')"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
:hint="$t('service_fee_hint')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service fee max</p>
|
||||
<p><span v-text="$t('service_fee_max')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee_max"
|
||||
label="Service fee max (sats)"
|
||||
hint="Max service fee to charge in (sats)"
|
||||
:label="$t('service_fee_max_label')"
|
||||
:hint="$t('service_fee_max_hint')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Fee Wallet</p>
|
||||
<p><span v-text="$t('fee_wallet')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_service_fee_wallet"
|
||||
label="Fee wallet (wallet ID)"
|
||||
hint="Wallet ID to send funds to"
|
||||
:label="$t('fee_wallet_label')"
|
||||
:hint="$t('fee_wallet_hint')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disable Service Fee for Internal Payments</p>
|
||||
<p><span v-text="$t('disable_fee_internal')"></span></p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Disable Fee</q-item-label>
|
||||
<q-item-label caption
|
||||
>Disable Service Fee for Internal Lightning
|
||||
Payments</q-item-label
|
||||
>
|
||||
<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-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
|
||||
@@ -1,90 +1,91 @@
|
||||
<q-tab-panel name="site_customisation">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">UI Management</h6>
|
||||
<h6 class="q-my-none"><span v-text="$t('ui_management')"></span></h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-5">
|
||||
<p>Site Title</p>
|
||||
<p><span v-text="$t('ui_site_title')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_title"
|
||||
label="Site title"
|
||||
:label="$t('ui_site_title')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<p>Site Tagline</p>
|
||||
<p><span v-text="$t('ui_site_tagline')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_tagline"
|
||||
label="Site tagline"
|
||||
:label="$t('ui_site_tagline')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-2 q-mt-xl">
|
||||
<q-toggle
|
||||
tip="Remove homepage elements like 'runs on' etc"
|
||||
:tip="$t('ui_toggle_elements_tip')"
|
||||
v-model="formData.lnbits_show_home_page_elements"
|
||||
:label="formData.lnbits_show_home_page_elements ? 'Enable elements on homepage' : 'Disable elements on homepage'"
|
||||
:label="formData.lnbits_show_home_page_elements ? $t('ui_elements_enable') : $t('ui_elements_disable')"
|
||||
></q-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>Site Description</p>
|
||||
<p><span v-text="$t('ui_site_description')"></span></p>
|
||||
<q-input
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
hint="Use plain text, Markdown, or raw HTML"
|
||||
:hint="$t('ui_site_description_hint')"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Default Wallet Name</p>
|
||||
<p><span v-text="$t('ui_default_wallet_name')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_wallet_name"
|
||||
label="LNbits wallet"
|
||||
:label="$t('lnbits_wallet')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Denomination</p>
|
||||
<p><span v-text="$t('denomination')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_denomination"
|
||||
label="sats"
|
||||
hint="The name for the FakeWallet token"
|
||||
:hint="$t('denomination_hint')"
|
||||
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<p>QR code logo</p>
|
||||
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_qr_logo"
|
||||
label="https://example.com/image.svg"
|
||||
hint="URL to logo image in QR code"
|
||||
:hint="$t('ui_qr_code_logo_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<p>Custom Badge</p>
|
||||
<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="formData.lnbits_custom_badge"
|
||||
label="Custom Badge 'USE WITH CAUTION - LNbits wallet is still in BETA'"
|
||||
v-model.trim="formData.lnbits_custom_badge"
|
||||
:label="$t('ui_custom_badge_label')"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
@@ -92,67 +93,127 @@
|
||||
filled
|
||||
v-model="formData.lnbits_custom_badge_color"
|
||||
:options="colors"
|
||||
label="Custom badge color"
|
||||
: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>Themes</p>
|
||||
<p><span v-text="$t('themes')"></span></p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_theme_options"
|
||||
multiple
|
||||
hint="Choose themes available for users"
|
||||
:hint="$t('themes_hint')"
|
||||
:options="lnbits_theme_options"
|
||||
label="Themes"
|
||||
:label="$t('themes')"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Custom Logo</p>
|
||||
<p><span v-text="$t('custom_logo')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_custom_logo"
|
||||
label="https://example.com/image.png"
|
||||
hint="URL to logo image"
|
||||
:hint="$t('custom_logo_hint')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Ad Space Title</p>
|
||||
<p><span v-text="$t('ad_space_title')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_ad_space_title"
|
||||
label="Supported by"
|
||||
:label="$t('ad_space_title_label')"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Advertisement Slots</p>
|
||||
<p><span v-text="$t('ad_slots')"></span></p>
|
||||
<q-input
|
||||
class="q-mb-md"
|
||||
filled
|
||||
v-model="formData.lnbits_ad_space"
|
||||
type="text"
|
||||
label="url;img_light_url;img_dark_url, url..."
|
||||
hint="Ad url and image filepaths in CSV format, extensions can choose to honor"
|
||||
:label="$t('ad_slots_label')"
|
||||
:hint="$t('ad_slots_hint')"
|
||||
>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_ad_space_enabled"
|
||||
:label="formData.lnbits_ad_space_enabled ? 'Ads enabled' : 'Ads disabled'"
|
||||
:label="formData.lnbits_ad_space_enabled ? $t('ads_enabled') : $t('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>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<q-tab-panel name="users">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none q-mb-sm">User Management</h6>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('user_management')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 q-pr-sm">
|
||||
<p>Admin Users</p>
|
||||
<p><span v-text="$t('admin_users')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
:label="$t('admin_users_label')"
|
||||
:hint="$t('admin_users_hint')"
|
||||
>
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
@@ -31,14 +33,14 @@
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Allowed Users</p>
|
||||
<p><span v-text="$t('allowed_users')"></span></p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
:label="$t('allowed_users_label')"
|
||||
:hint="$t('allowed_users_hint')"
|
||||
>
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
@@ -58,10 +60,11 @@
|
||||
<br />
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<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-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-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
|
||||
@@ -1,60 +1,75 @@
|
||||
{% 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">
|
||||
<span v-text="$t('save_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %} {% from "macros.jinja"
|
||||
import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
{% endblock %} {% block page %}
|
||||
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<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-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="checkChanges"
|
||||
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('restart')"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
class="q-ml-md"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
<span v-text="$t('restart_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
|
||||
<q-btn :label="$t('download_backup')" flat @click="downloadBackup"></q-btn>
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</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>
|
||||
<q-btn
|
||||
:label="$t('download_backup')"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('reset_defaults_tooltip')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +78,12 @@
|
||||
<q-card>
|
||||
<q-splitter>
|
||||
<template v-slot:before>
|
||||
<q-tabs v-model="tab" vertical active-color="primary">
|
||||
<q-tabs
|
||||
@update:model-value="showExchangeProvidersTab"
|
||||
v-model="tab"
|
||||
vertical
|
||||
active-color="primary"
|
||||
>
|
||||
<q-tab
|
||||
name="funding"
|
||||
icon="account_balance_wallet"
|
||||
@@ -80,23 +100,6 @@
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('security')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
:label="$q.screen.gt.sm ? $t('users') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('users')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="extensions"
|
||||
icon="extension"
|
||||
:label="$q.screen.gt.sm ? $t('extensions') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('extensions')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="server"
|
||||
icon="price_change"
|
||||
@@ -105,6 +108,31 @@
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('payments')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="exchange_providers"
|
||||
icon="show_chart"
|
||||
:label="$q.screen.gt.sm ? $t('exchanges') : null"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('exchanges')"></span></q-tooltip
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
icon="group"
|
||||
:label="$q.screen.gt.sm ? $t('users') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('users')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="extensions"
|
||||
icon="extension"
|
||||
:label="$q.screen.gt.sm ? $t('extensions') : null"
|
||||
@update="val => tab = val.name"
|
||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||
><span v-text="$t('extensions')"></span></q-tooltip
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
name="notifications"
|
||||
icon="notifications"
|
||||
@@ -145,7 +173,8 @@
|
||||
>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %}
|
||||
{% include "admin/_tab_extensions.html" %} {% include
|
||||
{% 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"%}
|
||||
@@ -157,6 +186,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/admin.js') }}"></script>
|
||||
<q-dialog v-model="exchangeData.showTickerConversion" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="q-mb-md">
|
||||
<strong v-text="$t('create_ticker_converter')"></strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 q-mb-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="exchangeData.convertFromTicker"
|
||||
label="From Currency"
|
||||
:options="{{ currencies | safe }}"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
v-model="exchangeData.convertToTicker"
|
||||
dense
|
||||
filled
|
||||
label="New Ticker"
|
||||
hint="This ticker will be used for the exchange API calls."
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="addExchangeTickerConversion()"
|
||||
label="Add Ticker Conversion"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
{% 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-xl">
|
||||
<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>Components</strong>
|
||||
<strong v-text="$t('components')"></strong>
|
||||
<div style="width: 250px" class="q-pa-sm">
|
||||
<canvas ref="componentUseChart"></canvas>
|
||||
<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>To 5 Long Running Endpoints</strong>
|
||||
<strong v-text="$t('long_running_endpoints')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas ref="longDurationChart"></canvas>
|
||||
<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>HTTP Request Methods</strong>
|
||||
<strong v-text="$t('http_request_methods')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas ref="requestMethodChart"></canvas>
|
||||
<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>HTTP Response Codes</strong>
|
||||
<strong v-text="$t('http_response_codes')"></strong>
|
||||
<div style="width: 250px; height: 250px" class="q-pa-sm">
|
||||
<canvas ref="responseCodeChart"></canvas>
|
||||
<canvas v-if="chartsReady" ref="responseCodeChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
@@ -70,6 +97,7 @@
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-select
|
||||
v-else-if="['component', 'response_code','request_method'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@@ -96,7 +124,9 @@
|
||||
class="cursor-pointer q-mr-xs"
|
||||
@click="showDetailsDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Request Details</q-tooltip>
|
||||
<q-tooltip
|
||||
><span v-text="$t('request_details')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
@@ -140,7 +170,7 @@
|
||||
|
||||
<q-dialog v-model="auditDetailsDialog.show" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<strong>HTTP Request Details</strong>
|
||||
<strong v-text="$t('http_request_details')"></strong>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
@@ -168,6 +198,4 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/audit.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -233,4 +233,14 @@
|
||||
</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>
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/account.js') }}"></script>
|
||||
{% endblock %} {% block page %}
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div>
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" align="left">
|
||||
<q-tab
|
||||
name="user"
|
||||
:label="$t('account_settings')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
:label="$t('look_and_feel')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="api_acls"
|
||||
:label="$t('access_control_list')"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div v-if="user" class="col-12 col-md-6 q-gutter-y-md">
|
||||
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-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">
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
@@ -129,11 +141,10 @@
|
||||
</q-card-section>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-card-section>
|
||||
<q-card-section v-if="user.extra.picture">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-img
|
||||
v-if="user.extra.picture"
|
||||
style="max-width: 100px"
|
||||
:src="user.extra.picture"
|
||||
class="float-right"
|
||||
@@ -141,7 +152,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section>
|
||||
<q-input
|
||||
@@ -286,6 +296,15 @@
|
||||
</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">
|
||||
@@ -325,7 +344,7 @@
|
||||
v-if="g.allowedThemes.includes('classic')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('classic')"
|
||||
@click="themeChoiceFunc('classic')"
|
||||
icon="circle"
|
||||
color="deep-purple"
|
||||
size="md"
|
||||
@@ -335,7 +354,7 @@
|
||||
v-if="g.allowedThemes.includes('bitcoin')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('bitcoin')"
|
||||
@click="themeChoiceFunc('bitcoin')"
|
||||
icon="circle"
|
||||
color="orange"
|
||||
size="md"
|
||||
@@ -345,7 +364,7 @@
|
||||
v-if="g.allowedThemes.includes('mint')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('mint')"
|
||||
@click="themeChoiceFunc('mint')"
|
||||
icon="circle"
|
||||
color="green"
|
||||
size="md"
|
||||
@@ -354,7 +373,7 @@
|
||||
v-if="g.allowedThemes.includes('autumn')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('autumn')"
|
||||
@click="themeChoiceFunc('autumn')"
|
||||
icon="circle"
|
||||
color="brown"
|
||||
size="md"
|
||||
@@ -364,7 +383,7 @@
|
||||
v-if="g.allowedThemes.includes('monochrome')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('monochrome')"
|
||||
@click="themeChoiceFunc('monochrome')"
|
||||
icon="circle"
|
||||
color="grey"
|
||||
size="md"
|
||||
@@ -374,7 +393,7 @@
|
||||
v-if="g.allowedThemes.includes('salvador')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('salvador')"
|
||||
@click="themeChoiceFunc('salvador')"
|
||||
icon="circle"
|
||||
color="blue-10"
|
||||
size="md"
|
||||
@@ -384,7 +403,7 @@
|
||||
v-if="g.allowedThemes.includes('freedom')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('freedom')"
|
||||
@click="themeChoiceFunc('freedom')"
|
||||
icon="circle"
|
||||
color="pink-13"
|
||||
size="md"
|
||||
@@ -394,7 +413,7 @@
|
||||
v-if="g.allowedThemes.includes('cyber')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('cyber')"
|
||||
@click="themeChoiceFunc('cyber')"
|
||||
icon="circle"
|
||||
color="light-green-9"
|
||||
size="md"
|
||||
@@ -404,7 +423,7 @@
|
||||
v-if="g.allowedThemes.includes('flamingo')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('flamingo')"
|
||||
@click="themeChoiceFunc('flamingo')"
|
||||
icon="circle"
|
||||
color="pink-3"
|
||||
size="md"
|
||||
@@ -412,6 +431,22 @@
|
||||
</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>
|
||||
@@ -458,9 +493,9 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="borderChoice"
|
||||
v-model="borderSelection"
|
||||
:options="borderOptions"
|
||||
label="Reactions"
|
||||
label="Borders"
|
||||
@update:model-value="applyBorder"
|
||||
>
|
||||
<q-tooltip
|
||||
@@ -470,7 +505,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">Notifications</div>
|
||||
<div class="col-4">
|
||||
<span v-text="$t('notifications')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<lnbits-notifications-btn
|
||||
v-if="g.user"
|
||||
@@ -484,7 +521,7 @@
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="reactionChoice"
|
||||
v-model="reactionSelection"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@update:model-value="reactionChoiceFunc"
|
||||
@@ -496,6 +533,230 @@
|
||||
</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>
|
||||
<code>Authorization</code>
|
||||
|
||||
</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 </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>
|
||||
@@ -510,4 +771,126 @@
|
||||
</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 %}
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {{ window_vars(user, extensions) }}{% block page %}
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-sm-9 col-xs-12">
|
||||
<p class="text-h4 gt-sm" v-text="$t('extensions')"></p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3 col-xs-12 q-ml-auto">
|
||||
<q-input v-model="searchTerm" :label="$t('search_extensions')">
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet, extensions, extension_data)
|
||||
}}{% endblock %} {% block page %}
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-12">
|
||||
<q-card>
|
||||
<div class="q-pa-xs">
|
||||
<div>
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs
|
||||
:model-value="tab"
|
||||
@@ -35,6 +23,44 @@
|
||||
v-if="!g.user.admin && tab != 'installed'"
|
||||
v-text="$t('only_admins_can_install')"
|
||||
></i>
|
||||
<q-space></q-space>
|
||||
<q-input
|
||||
:label="$t('search_extensions')"
|
||||
:dense="dense"
|
||||
class="float-right q-pr-xl"
|
||||
v-model="searchTerm"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<q-icon name="search"> </q-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
v-if="searchTerm !== ''"
|
||||
name="close"
|
||||
@click="searchTerm = ''"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-badge
|
||||
v-if="g.user.admin && updatableExtensions?.length"
|
||||
@click="showUpdateAllDialog = true"
|
||||
color="primary"
|
||||
class="float-right q-pa-sm q-mr-md cursor-pointer"
|
||||
>
|
||||
<span
|
||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||
></span>
|
||||
</q-badge>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#extensions"
|
||||
><q-tooltip v-text="$t('admin_settings')"></q-tooltip
|
||||
></q-btn>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,6 +69,7 @@
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
v-if="filteredExtensions"
|
||||
class="col-12 col-sm-6 col-md-6 col-lg-4"
|
||||
v-for="extension in filteredExtensions"
|
||||
:key="extension.id + extension.hash"
|
||||
@@ -91,6 +118,7 @@
|
||||
></q-tooltip>
|
||||
</q-badge>
|
||||
<div
|
||||
v-if="extension.name"
|
||||
class="text-h5"
|
||||
style="cursor: pointer"
|
||||
@click="showExtensionDetails(extension.id, extension.details_link)"
|
||||
@@ -163,7 +191,7 @@
|
||||
<div class="col-10">
|
||||
<div v-if="!extension.inProgress">
|
||||
<q-btn
|
||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
color="primary"
|
||||
type="a"
|
||||
@@ -171,20 +199,20 @@
|
||||
:label="$t('open')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
v-if="g.user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
|
||||
flat
|
||||
color="grey-5"
|
||||
@click="disableExtension(extension)"
|
||||
:label="$t('disable')"
|
||||
></q-btn>
|
||||
<q-badge
|
||||
v-if="extension.isAdminOnly && !user.admin"
|
||||
v-if="extension.isAdminOnly && !g.user.admin"
|
||||
v-text="$t('admin_only')"
|
||||
>
|
||||
</q-badge>
|
||||
|
||||
<q-btn
|
||||
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
|
||||
v-else-if="extension.isInstalled && extension.isActive && !g.user.extensions.includes(extension.id)"
|
||||
flat
|
||||
color="primary"
|
||||
@click="enableExtensionForUser(extension)"
|
||||
@@ -207,7 +235,7 @@
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +257,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="showUninstallDialog">
|
||||
<q-dialog v-model="showUninstallDialog" position="top">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p>
|
||||
@@ -266,7 +294,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showDropDbDialog">
|
||||
<q-dialog v-model="showDropDbDialog" position="top">
|
||||
<q-card v-if="selectedExtension" class="q-pa-lg">
|
||||
<h6 class="q-my-md text-primary" v-text="$t('warning')"></h6>
|
||||
<p><span v-text="$t('extension_db_drop_warning')"></span><br /></p>
|
||||
@@ -296,7 +324,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showManageExtensionDialog">
|
||||
<q-dialog v-model="showManageExtensionDialog" position="top">
|
||||
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div v-if="selectedRelease.paymentRequest">
|
||||
@@ -309,7 +337,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -408,7 +436,10 @@
|
||||
:header-class="getReleaseIconColor(release)"
|
||||
>
|
||||
<div v-if="release.inProgress">
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
<q-spinner-bars
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner-bars>
|
||||
</div>
|
||||
<div v-else-if="release.error">
|
||||
<q-icon
|
||||
@@ -419,7 +450,7 @@
|
||||
></q-icon>
|
||||
<span v-text="$t('release_details_error')"></span>
|
||||
</div>
|
||||
<q-card v-else>
|
||||
<q-card class="no-border" v-else>
|
||||
<q-card-section v-if="release.is_version_compatible">
|
||||
<span
|
||||
v-if="release.requiresPayment && !release.paid_sats"
|
||||
@@ -527,10 +558,26 @@
|
||||
></a>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
<span v-text="$t('extension_min_lnbits_version')"></span>
|
||||
<strong>
|
||||
<span v-text="release.min_lnbits_version"></span>
|
||||
</strong>
|
||||
<span
|
||||
v-text="$t('extension_required_lnbits_version')"
|
||||
></span>
|
||||
<span class="q-mr-sm">:</span>
|
||||
<ul>
|
||||
<li v-if="release.min_lnbits_version">
|
||||
<span v-text="$t('min_version')"></span>
|
||||
<span class="q-mr-sm">:</span>
|
||||
<strong>
|
||||
<span v-text="release.min_lnbits_version"></span>
|
||||
</strong>
|
||||
</li>
|
||||
<li v-if="release.max_lnbits_version">
|
||||
<span v-text="$t('max_version')"></span>
|
||||
<span class="q-mr-sm">:</span>
|
||||
<strong>
|
||||
<span v-text="release.max_lnbits_version"></span>
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
<q-card v-if="release.warning">
|
||||
<q-card-section>
|
||||
@@ -578,7 +625,7 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-spinner v-else color="primary" size="2.55em"></q-spinner>
|
||||
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
|
||||
<div
|
||||
v-if="selectedExtension"
|
||||
v-show="manageExtensionTab === 'sell'"
|
||||
@@ -632,7 +679,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showPayToEnableDialog">
|
||||
<q-dialog v-model="showPayToEnableDialog" position="top">
|
||||
<q-card v-if="selectedExtension" class="q-pa-md">
|
||||
<q-card-section>
|
||||
<p>
|
||||
@@ -664,7 +711,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="col">
|
||||
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
@@ -738,7 +785,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showExtensionDetailsDialog">
|
||||
<q-dialog v-model="showExtensionDetailsDialog" position="top">
|
||||
<q-card
|
||||
v-if="selectedExtensionDetails"
|
||||
class="q-pa-sm"
|
||||
@@ -884,617 +931,61 @@
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
<q-dialog v-model="showUpdateAllDialog" position="top">
|
||||
<q-card class="q-pa-md q-pt-md lnbits__dialog-card">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="q-my-md" v-text="$t('update')"></h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="updatableExtensions?.length > 1" class="row">
|
||||
<div class="col-12">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="selectAllUpdatableExtensionss()"
|
||||
v-text="$t('select_all')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
slide: 0,
|
||||
fullscreen: false,
|
||||
autoplay: true,
|
||||
searchTerm: '',
|
||||
tab: 'all',
|
||||
manageExtensionTab: 'releases',
|
||||
filteredExtensions: null,
|
||||
showUninstallDialog: false,
|
||||
showManageExtensionDialog: false,
|
||||
showExtensionDetailsDialog: false,
|
||||
showDropDbDialog: false,
|
||||
showPayToEnableDialog: false,
|
||||
dropDbExtensionId: '',
|
||||
selectedExtension: null,
|
||||
selectedImage: null,
|
||||
selectedExtensionDetails: null,
|
||||
selectedExtensionRepos: null,
|
||||
selectedRelease: null,
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null,
|
||||
user: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
this.filterExtensions(term, this.tab)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleTabChanged: function (tab) {
|
||||
this.filterExtensions(this.searchTerm, tab)
|
||||
},
|
||||
filterExtensions: function (term, tab) {
|
||||
// Filter the extensions list
|
||||
function extensionNameContains(searchTerm) {
|
||||
return function (extension) {
|
||||
return (
|
||||
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
extension.shortDescription
|
||||
?.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}
|
||||
}
|
||||
<q-virtual-scroll :items="updatableExtensions" style="max-height: 400px">
|
||||
<template v-slot="{ item, index }">
|
||||
<div class="row">
|
||||
<div class="q-col">
|
||||
<q-checkbox
|
||||
v-model="item.selectedForUpdate"
|
||||
:disable="item.isUpgraded"
|
||||
value="false"
|
||||
:label="item.name + ` (v${item.latestRelease?.version})`"
|
||||
>
|
||||
<q-spinner-bars
|
||||
v-if="item.inProgress"
|
||||
color="primary"
|
||||
size="1.5em"
|
||||
class="q-ml-md"
|
||||
></q-spinner-bars>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</q-virtual-scroll>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@click="updateSelectedExtensions()"
|
||||
outline
|
||||
color="grey"
|
||||
v-text="$t('update')"
|
||||
></q-btn>
|
||||
|
||||
this.filteredExtensions = this.extensions
|
||||
.filter(e => (tab === 'all' ? !e.isInstalled : true))
|
||||
.filter(e => (tab === 'installed' ? e.isInstalled : true))
|
||||
.filter(e =>
|
||||
tab === 'installed'
|
||||
? e.isActive
|
||||
? true
|
||||
: !!this.g.user.admin
|
||||
: true
|
||||
)
|
||||
.filter(e => (tab === 'featured' ? e.isFeatured : true))
|
||||
.filter(extensionNameContains(term))
|
||||
.map(e => ({
|
||||
...e,
|
||||
details_link:
|
||||
e.installedRelease?.details_link || e.latestRelease?.details_link
|
||||
}))
|
||||
this.tab = tab
|
||||
},
|
||||
|
||||
installExtension: async function (release) {
|
||||
// no longer required to check if the invoice was paid
|
||||
// the install logic has been triggered one way or another
|
||||
this.unsubscribeFromPaylinkWs()
|
||||
|
||||
const extension = this.selectedExtension
|
||||
extension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
release.payment_hash =
|
||||
release.payment_hash || this.getPaylinkHash(release.pay_link)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
`/api/v1/extension`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: extension.id,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo,
|
||||
payment_hash: release.payment_hash,
|
||||
version: release.version
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
extension.isAvailable = true
|
||||
extension.isInstalled = true
|
||||
extension.installedRelease = release
|
||||
this.toggleExtension(extension)
|
||||
extension.inProgress = false
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
window.location.reload()
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn(err)
|
||||
extension.inProgress = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
uninstallExtension: async function () {
|
||||
const extension = this.selectedExtension
|
||||
this.showManageExtensionDialog = false
|
||||
this.showUninstallDialog = false
|
||||
extension.inProgress = true
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
extension.isAvailable = false
|
||||
extension.isInstalled = false
|
||||
extension.inProgress = false
|
||||
extension.installedRelease = null
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Extension uninstalled!'
|
||||
})
|
||||
if (this.uninstallAndDropDb) {
|
||||
this.showDropDb()
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
|
||||
dropExtensionDb: async function () {
|
||||
const extension = this.selectedExtension
|
||||
this.showManageExtensionDialog = false
|
||||
this.showDropDbDialog = false
|
||||
this.dropDbExtensionId = ''
|
||||
extension.inProgress = true
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${extension.id}/db`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
extension.installedRelease = null
|
||||
extension.inProgress = false
|
||||
extension.hasDatabaseTables = false
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Extension DB deleted!'
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 300)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
toggleExtension(extension) {
|
||||
const action = extension.isActive ? 'activate' : 'deactivate'
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/${action}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: `Extension '${extension.id}' ${action}d!`
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.isActive = false
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
enableExtensionForUser: function (extension) {
|
||||
if (extension.isPaymentRequired) {
|
||||
this.showPayToEnable(extension)
|
||||
return
|
||||
}
|
||||
this.enableExtension(extension)
|
||||
},
|
||||
enableExtension: function (extension) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/enable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Extension enabled!'
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 300)
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
disableExtension: function (extension) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/disable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Extension disabled!'
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 300)
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
showPayToEnable: function (extension) {
|
||||
this.selectedExtension = extension
|
||||
this.selectedExtension.payToEnable.paidAmount =
|
||||
extension.payToEnable.amount
|
||||
this.selectedExtension.payToEnable.showQRCode = false
|
||||
this.showPayToEnableDialog = true
|
||||
},
|
||||
updatePayToInstallData: function (extension) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/sell`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
required: extension.payToEnable.required,
|
||||
amount: extension.payToEnable.amount,
|
||||
wallet: extension.payToEnable.wallet
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Payment info updated!'
|
||||
})
|
||||
this.showManageExtensionDialog = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
extension.inProgress = false
|
||||
})
|
||||
},
|
||||
|
||||
showUninstall: function () {
|
||||
this.showManageExtensionDialog = false
|
||||
this.showUninstallDialog = true
|
||||
this.uninstallAndDropDb = false
|
||||
},
|
||||
|
||||
showDropDb: function () {
|
||||
this.showDropDbDialog = true
|
||||
},
|
||||
|
||||
showManageExtension: async function (extension) {
|
||||
this.selectedExtension = extension
|
||||
this.selectedRelease = null
|
||||
this.selectedExtensionRepos = null
|
||||
this.manageExtensionTab = 'releases'
|
||||
this.showManageExtensionDialog = true
|
||||
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extension.id}/releases`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
this.selectedExtensionRepos = data.reduce((repos, release) => {
|
||||
repos[release.source_repo] = repos[release.source_repo] || {
|
||||
releases: [],
|
||||
isInstalled: false,
|
||||
repo: release.repo
|
||||
}
|
||||
release.inProgress = false
|
||||
release.error = null
|
||||
release.loaded = false
|
||||
release.isInstalled = this.isInstalledVersion(
|
||||
this.selectedExtension,
|
||||
release
|
||||
)
|
||||
if (release.isInstalled) {
|
||||
repos[release.source_repo].isInstalled = true
|
||||
}
|
||||
if (release.pay_link) {
|
||||
release.requiresPayment = true
|
||||
release.paidAmount = release.cost_sats
|
||||
release.payment_hash = this.getPaylinkHash(release.pay_link)
|
||||
}
|
||||
|
||||
repos[release.source_repo].releases.push(release)
|
||||
return repos
|
||||
}, {})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
extension.inProgress = false
|
||||
}
|
||||
},
|
||||
|
||||
showExtensionDetails: async function (extId, detailsLink) {
|
||||
if (!detailsLink) {
|
||||
return
|
||||
}
|
||||
this.selectedExtensionDetails = null
|
||||
this.showExtensionDetailsDialog = true
|
||||
this.slide = 0
|
||||
this.fullscreen = false
|
||||
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
|
||||
this.selectedExtensionDetails = data
|
||||
this.selectedExtensionDetails.description_md =
|
||||
LNbits.utils.convertMarkdown(data.description_md)
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
async payAndInstall(release) {
|
||||
try {
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
const paymentInfo = await this.requestPaymentForInstall(
|
||||
this.selectedExtension.id,
|
||||
release
|
||||
)
|
||||
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
|
||||
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
|
||||
const {data} = await LNbits.api.payInvoice(
|
||||
wallet,
|
||||
paymentInfo.payment_request
|
||||
)
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
|
||||
await this.installExtension(release)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
} finally {
|
||||
this.selectedExtension.inProgress = false
|
||||
}
|
||||
},
|
||||
async payAndEnable(extension) {
|
||||
try {
|
||||
const paymentInfo = await this.requestPaymentForEnable(
|
||||
extension.id,
|
||||
extension.payToEnable.paidAmount
|
||||
)
|
||||
|
||||
const wallet = this.g.user.wallets.find(
|
||||
w => w.id === extension.payToEnable.paymentWallet
|
||||
)
|
||||
const {data} = await LNbits.api.payInvoice(
|
||||
wallet,
|
||||
paymentInfo.payment_request
|
||||
)
|
||||
this.enableExtension(extension)
|
||||
this.showPayToEnableDialog = false
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
async showInstallQRCode(release) {
|
||||
this.selectedRelease = release
|
||||
|
||||
try {
|
||||
const data = await this.requestPaymentForInstall(
|
||||
this.selectedExtension.id,
|
||||
release
|
||||
)
|
||||
|
||||
this.selectedRelease.paymentRequest = data.payment_request
|
||||
this.selectedRelease.payment_hash = data.payment_hash
|
||||
this.selectedRelease = _.clone(this.selectedRelease)
|
||||
this.rememberPaylinkHash(
|
||||
this.selectedRelease.pay_link,
|
||||
this.selectedRelease.payment_hash
|
||||
)
|
||||
|
||||
this.subscribeToPaylinkWs(
|
||||
this.selectedRelease.pay_link,
|
||||
data.payment_hash
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async showEnableQRCode(extension) {
|
||||
try {
|
||||
extension.payToEnable.showQRCode = true
|
||||
this.selectedExtension = _.clone(extension)
|
||||
|
||||
const data = await this.requestPaymentForEnable(
|
||||
extension.id,
|
||||
extension.payToEnable.paidAmount
|
||||
)
|
||||
extension.payToEnable.paymentRequest = data.payment_request
|
||||
this.selectedExtension = _.clone(extension)
|
||||
|
||||
const url = new URL(window.location)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||
url.pathname = `/api/v1/ws/${data.payment_hash}`
|
||||
const ws = new WebSocket(url)
|
||||
ws.addEventListener('message', async ({data}) => {
|
||||
const payment = JSON.parse(data)
|
||||
if (payment.pending === false) {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Invoice Paid!'
|
||||
})
|
||||
|
||||
this.enableExtension(extension)
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async requestPaymentForInstall(extId, release) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extId}/invoice/install`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
ext_id: extId,
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo,
|
||||
cost_sats: release.paidAmount,
|
||||
version: release.version
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
async requestPaymentForEnable(extId, amount) {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extId}/invoice/enable`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
amount
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
clearHangingInvoice(release) {
|
||||
this.forgetPaylinkHash(release.pay_link)
|
||||
release.payment_hash = null
|
||||
},
|
||||
|
||||
rememberPaylinkHash(pay_link, payment_hash) {
|
||||
this.$q.localStorage.set(
|
||||
`lnbits.extensions.paylink.${pay_link}`,
|
||||
payment_hash
|
||||
)
|
||||
},
|
||||
getPaylinkHash(pay_link) {
|
||||
return this.$q.localStorage.getItem(
|
||||
`lnbits.extensions.paylink.${pay_link}`
|
||||
)
|
||||
},
|
||||
forgetPaylinkHash(pay_link) {
|
||||
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
|
||||
},
|
||||
subscribeToPaylinkWs(pay_link, payment_hash) {
|
||||
const url = new URL(`${pay_link}/${payment_hash}`)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||
this.paylinkWebsocket = new WebSocket(url)
|
||||
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||
const resp = JSON.parse(data)
|
||||
if (resp.paid) {
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Invoice Paid!'
|
||||
})
|
||||
this.installExtension(this.selectedRelease)
|
||||
} else {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: 'Invoice tracking lost!'
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
unsubscribeFromPaylinkWs() {
|
||||
try {
|
||||
this.paylinkWebsocket && this.paylinkWebsocket.close()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
},
|
||||
|
||||
hasNewVersion: function (extension) {
|
||||
if (extension.installedRelease && extension.latestRelease) {
|
||||
return (
|
||||
extension.installedRelease.version !==
|
||||
extension.latestRelease.version
|
||||
)
|
||||
}
|
||||
},
|
||||
isInstalledVersion: function (extension, release) {
|
||||
if (extension.installedRelease) {
|
||||
return (
|
||||
extension.installedRelease.source_repo === release.source_repo &&
|
||||
extension.installedRelease.version === release.version
|
||||
)
|
||||
}
|
||||
},
|
||||
getReleaseIcon: function (release) {
|
||||
if (!release.is_version_compatible) return 'block'
|
||||
if (release.isInstalled) return 'download_done'
|
||||
|
||||
return 'download'
|
||||
},
|
||||
getReleaseIconColor: function (release) {
|
||||
if (!release.is_version_compatible) return 'text-red'
|
||||
if (release.isInstalled) return 'text-green'
|
||||
|
||||
return ''
|
||||
},
|
||||
getGitHubReleaseDetails: async function (release) {
|
||||
if (!release.is_github_release || release.loaded) {
|
||||
return
|
||||
}
|
||||
const [org, repo] = release.source_repo.split('/')
|
||||
release.inProgress = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/release/${org}/${repo}/${release.version}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
release.loaded = true
|
||||
release.is_version_compatible = data.is_version_compatible
|
||||
release.min_lnbits_version = data.min_lnbits_version
|
||||
release.warning = data.warning
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
release.error = error
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
release.inProgress = false
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.extensions = JSON.parse('{{extensions | tojson | safe}}').map(e => ({
|
||||
...e,
|
||||
inProgress: false
|
||||
}))
|
||||
this.filteredExtensions = this.extensions.concat([])
|
||||
for (let i = 0; i < this.filteredExtensions.length; i++) {
|
||||
if (this.filteredExtensions[i].isInstalled != false) {
|
||||
this.handleTabChanged('installed')
|
||||
this.tab = 'installed'
|
||||
}
|
||||
}
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
},
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
</script>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,139 +1,150 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center main">
|
||||
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section class="grid">
|
||||
<div>
|
||||
<h6 class="q-my-none text-center">
|
||||
<strong>Welcome to LNbits</strong>
|
||||
<p>Set up the Superuser account below.</p>
|
||||
</h6>
|
||||
<br />
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="loginData.username"
|
||||
:label="$t('username')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.password"
|
||||
:type="loginData.isPwd ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwd = !loginData.isPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.passwordRepeat"
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="setPassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('login')"
|
||||
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero q-mx-auto"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid {
|
||||
display: block;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
{% extends "public.html" %} {% block page_container %}
|
||||
<q-page-container>
|
||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md justify-center main">
|
||||
<div class="col-10 col-md-8 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section class="grid">
|
||||
<div>
|
||||
<h6 class="q-my-none text-center">
|
||||
<strong v-text="$t('welcome_lnbits')"></strong>
|
||||
<p><span v-text="$t('setup_su_account')"></span></p>
|
||||
</h6>
|
||||
<br />
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="loginData.username"
|
||||
:label="$t('username')"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.password"
|
||||
:type="loginData.isPwd ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password')]"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwd ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwd = !loginData.isPwd"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="loginData.passwordRepeat"
|
||||
:type="loginData.isPwdRepeat ? 'password' : 'text'"
|
||||
autocomplete="off"
|
||||
:label="$t('password_repeat')"
|
||||
:rules="[(val) => !val || val.length >= 8 || $t('invalid_password'), (val) => val === loginData.password || 'Passwords_dont_match']"
|
||||
><template v-slot:append>
|
||||
<q-icon
|
||||
:name="loginData.isPwdRepeat ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="loginData.isPwdRepeat = !loginData.isPwdRepeat"
|
||||
/> </template
|
||||
></q-input>
|
||||
<q-btn
|
||||
@click="setPassword()"
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="$t('login')"
|
||||
:disable="checkPasswordsMatch || !loginData.username || !loginData.password || !loginData.passwordRepeat"
|
||||
></q-btn>
|
||||
</q-form>
|
||||
</div>
|
||||
<div class="hero-wrapper">
|
||||
<div class="hero q-mx-auto"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<style>
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.grid {
|
||||
display: block;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
background-image: url("{{ static_url_for('static', 'images/logos/lnbits.svg') }}");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
isPwd: true,
|
||||
isPwdRepeat: true,
|
||||
username: '',
|
||||
password: '',
|
||||
passwordRepeat: ''
|
||||
@media (min-width: 992px) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
.hero-wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||
},
|
||||
computed: {
|
||||
checkPasswordsMatch() {
|
||||
return this.loginData.password !== this.loginData.passwordRepeat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async setPassword() {
|
||||
try {
|
||||
await LNbits.api.request('PUT', '/api/v1/auth/first_install', null, {
|
||||
username: this.loginData.username,
|
||||
password: this.loginData.password,
|
||||
password_repeat: this.loginData.passwordRepeat
|
||||
})
|
||||
</style>
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
loginData: {
|
||||
isPwd: true,
|
||||
isPwdRepeat: true,
|
||||
username: '',
|
||||
password: '',
|
||||
passwordRepeat: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.hasAdminUI = '{{ LNBITS_ADMIN_UI | tojson}}'
|
||||
},
|
||||
computed: {
|
||||
checkPasswordsMatch() {
|
||||
return this.loginData.password !== this.loginData.passwordRepeat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async setPassword() {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/first_install',
|
||||
null,
|
||||
{
|
||||
username: this.loginData.username,
|
||||
password: this.loginData.password,
|
||||
password_repeat: this.loginData.passwordRepeat
|
||||
}
|
||||
)
|
||||
|
||||
window.location.href = '/admin'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
window.location.href = '/admin'
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
|
||||
{% endblock %}
|
||||
<!---->
|
||||
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
<!---->
|
||||
{% block page %}
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}{% endblock %} {% block page
|
||||
%}
|
||||
<div class="row q-col-gutter-md">
|
||||
{% if HIDE_API and AD_SPACE %}
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
@@ -28,38 +23,126 @@
|
||||
<q-card
|
||||
:style="$q.screen.lt.md ? {
|
||||
background: $q.screen.lt.md ? 'none !important': ''
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, margin: $q.screen.lt.md && mobileSimple ? 'auto !important': ''
|
||||
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
|
||||
} : ''"
|
||||
, boxShadow: $q.screen.lt.md ? 'none !important': ''
|
||||
, border: $q.screen.lt.md ? 'none !important': ''
|
||||
, width: $q.screen.lt.md && mobileSimple ? '90% !important': ''
|
||||
} : ''"
|
||||
>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none text-no-wrap">
|
||||
<strong v-text="formattedBalance"></strong>
|
||||
<small> {{LNBITS_DENOMINATION}}</small>
|
||||
<lnbits-update-balance
|
||||
:wallet_id="this.g.wallet.id"
|
||||
flat
|
||||
:callback="updateBalanceCallback"
|
||||
round
|
||||
/>
|
||||
</h3>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div v-if="g.wallet.currency">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
v-text="formattedFiatBalance"
|
||||
style="opacity: 0.75"
|
||||
></span>
|
||||
</div>
|
||||
<q-card-section style="height: 130px">
|
||||
<div class="row q-gutter-md">
|
||||
<div
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
class="col-1"
|
||||
style="max-width: 30px"
|
||||
>
|
||||
<q-btn
|
||||
v-if="g.fiatTracking"
|
||||
@click="swapBalancePriority"
|
||||
style="height: 50px"
|
||||
class="q-mt-lg"
|
||||
color="primary"
|
||||
flat
|
||||
dense
|
||||
icon="swap_vert"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div
|
||||
class="column"
|
||||
:class="{
|
||||
'q-pt-sm': g.fiatTracking,
|
||||
'q-pt-lg': !g.fiatTracking
|
||||
}"
|
||||
v-if="!isFiatPriority || !g.fiatTracking"
|
||||
style="height: 100px"
|
||||
>
|
||||
<div class="col-7">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<div class="text-h3 q-my-none text-no-wrap">
|
||||
<strong
|
||||
v-text="walletFormatBalance(this.g.wallet.sat)"
|
||||
></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<lnbits-update-balance
|
||||
v-if="$q.screen.lt.lg"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
:callback="updateBalanceCallback"
|
||||
:small_btn="true"
|
||||
></lnbits-update-balance>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<div v-if="g.fiatTracking">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
v-text="formattedFiatAmount"
|
||||
style="opacity: 0.75"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="column"
|
||||
v-if="isFiatPriority && g.fiatTracking"
|
||||
:class="{
|
||||
'q-pt-sm': g.fiatTracking,
|
||||
'q-pt-lg': !g.fiatTracking
|
||||
}"
|
||||
style="height: 100px"
|
||||
>
|
||||
<div class="col-7">
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<div
|
||||
v-if="g.fiatTracking"
|
||||
class="text-h3 q-my-none text-no-wrap"
|
||||
>
|
||||
<strong v-text="formattedFiatAmount"></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<lnbits-update-balance
|
||||
v-if="$q.screen.lt.lg"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
:callback="updateBalanceCallback"
|
||||
:small_btn="true"
|
||||
></lnbits-update-balance>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<span
|
||||
class="text-h5 text-italic"
|
||||
style="opacity: 0.75"
|
||||
v-text="formattedBalance + ' {{LNBITS_DENOMINATION}}'"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute-right q-pa-md"
|
||||
v-if="$q.screen.gt.md && g.fiatTracking && '{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
>
|
||||
<div class="text-bold text-italic">BTC Price</div>
|
||||
<span
|
||||
class="text-bold text-italic"
|
||||
v-text="formattedExchange"
|
||||
></span>
|
||||
</div>
|
||||
<q-btn
|
||||
@click="mobileSimple = !mobileSimple"
|
||||
v-if="$q.screen.lt.md"
|
||||
@click="simpleMobile()"
|
||||
color="primary"
|
||||
class="float-right lt-md"
|
||||
class="q-ml-xl absolute-right"
|
||||
dense
|
||||
size="sm"
|
||||
style="height: 20px; margin-top: 75px"
|
||||
flat
|
||||
:icon="mobileSimple ? 'unfold_more' : 'unfold_less'"
|
||||
:label="mobileSimple ? $t('more') : $t('less')"
|
||||
@@ -72,41 +155,39 @@
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="full-width"
|
||||
class="q-mr-md"
|
||||
@click="showParseDialog"
|
||||
:label="$t('paste_request')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
class="full-width"
|
||||
class="q-mr-md"
|
||||
@click="showReceiveDialog"
|
||||
:label="$t('create_invoice')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
icon="photo_camera"
|
||||
@click="showCamera"
|
||||
:label="$t('scan')"
|
||||
>
|
||||
<q-btn unelevated color="secondary" icon="qr_code_scanner">
|
||||
<q-tooltip
|
||||
><span v-text="$t('camera_tooltip')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
<lnbits-update-balance
|
||||
v-if="$q.screen.gt.md"
|
||||
:wallet_id="this.g.wallet.id"
|
||||
:callback="updateBalanceCallback"
|
||||
:small_btn="false"
|
||||
></lnbits-update-balance>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card
|
||||
:style="
|
||||
$q.screen.lt.md
|
||||
? {
|
||||
background: $q.screen.lt.md ? 'none !important' : '',
|
||||
boxShadow: $q.screen.lt.md ? 'none !important' : '',
|
||||
border: $q.screen.lt.md ? 'none !important': '',
|
||||
marginTop: $q.screen.lt.md ? '0px !important' : ''
|
||||
}
|
||||
: ''
|
||||
@@ -114,26 +195,47 @@
|
||||
>
|
||||
<q-card-section>
|
||||
<payment-list
|
||||
@filter-changed="handleFilterChange"
|
||||
:update="updatePayments"
|
||||
:wallet="this.g.wallet"
|
||||
:mobile-simple="mobileSimple"
|
||||
/>
|
||||
:expand-details="expandDetails"
|
||||
></payment-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<div id="hiddenQrCodeContainer" style="display: none">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + this.receive.paymentReq"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</div>
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
{% else %}
|
||||
<div
|
||||
v-if="!mobileSimple || $q.screen.gt.sm"
|
||||
class="col-12 col-md-5 q-gutter-y-md"
|
||||
>
|
||||
<div v-if="!mobileSimple" class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em>{{wallet_name}}</em></strong>
|
||||
</h6>
|
||||
<q-card-section class="q-pb-xs">
|
||||
<div class="row items-center">
|
||||
<q-avatar
|
||||
size="lg"
|
||||
:icon="g.wallet.extra.icon"
|
||||
:text-color="$q.dark.isActive ? 'black' : 'grey-3'"
|
||||
:color="g.wallet.extra.color"
|
||||
>
|
||||
</q-avatar>
|
||||
<q-btn
|
||||
@click="icon.show = true"
|
||||
round
|
||||
color="grey-5"
|
||||
text-color="black"
|
||||
size="xs"
|
||||
icon="edit"
|
||||
style="position: relative; left: -15px; bottom: -10px"
|
||||
></q-btn>
|
||||
<div class="text-subtitle1 q-mt-none q-mb-none">
|
||||
{{ SITE_TITLE }} Wallet:
|
||||
<strong><em v-text="g.wallet.name"></em></strong>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
@@ -160,99 +262,196 @@
|
||||
{% endif %}
|
||||
|
||||
<q-expansion-item
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
group="extras"
|
||||
icon="settings_cell"
|
||||
:label="$t('export_to_phone')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
|
||||
</q-card-section>
|
||||
<span v-text="exportWalletQR"></span>
|
||||
<q-card-actions class="flex-center q-pb-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
:label="$t('copy_wallet_url')"
|
||||
@click="copyText(exportUrl)"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="edit"
|
||||
:label="$t('rename_wallet')"
|
||||
icon="phone_android"
|
||||
:label="$t('access_wallet_on_mobile')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="" style="max-width: 320px">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="update.name"
|
||||
label="Name"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_name')"
|
||||
@click="updateWallet({ name: update.name })"
|
||||
></q-btn>
|
||||
You can connect to this wallet from a mobile app:
|
||||
<ul>
|
||||
<li>
|
||||
Download
|
||||
<a class="text-secondary" href="https://zeusln.app"
|
||||
>Zeus</a
|
||||
>
|
||||
or
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://bluewallet.io/"
|
||||
>BlueWallet</a
|
||||
>
|
||||
from App Store or Google Play
|
||||
</li>
|
||||
<li>
|
||||
Enable the
|
||||
<a class="text-secondary" href="/lndhub">LndHub </a>
|
||||
extension for this account
|
||||
</li>
|
||||
<li>
|
||||
Scan the QR code in the
|
||||
<a class="text-secondary" href="/lndhub">LndHub </a>
|
||||
extensions with your mobile app
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
Or you can access the wallet directly from your mobile
|
||||
browser using:
|
||||
<q-expansion-item
|
||||
icon="mobile_friendly"
|
||||
:label="$t('export_to_phone')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p v-text="$t('export_to_phone_desc')"></p>
|
||||
<lnbits-qrcode
|
||||
:value="`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`"
|
||||
></lnbits-qrcode>
|
||||
</q-card-section>
|
||||
<span v-text="exportWalletQR"></span>
|
||||
<q-card-actions class="flex-center q-pb-md">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
:label="$t('copy_wallet_url')"
|
||||
@click="copyText(`${baseUrl}wallet?usr=${g.user.id}&wal=${g.wallet.id}`)"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="attach_money"
|
||||
:label="$t('fiat_tracking')"
|
||||
icon="settings"
|
||||
:label="$t('wallet_config')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div style="max-width: 360px">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:label="$t('currency')"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
></q-select>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
filled
|
||||
v-model.trim="update.name"
|
||||
label="Name"
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4 q-pl-sm">
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-xs full-width"
|
||||
color="primary"
|
||||
:label="$t('update_name')"
|
||||
dense
|
||||
@click="updateWallet({ name: update.name })"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section
|
||||
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="update.currency"
|
||||
type="text"
|
||||
:disable="g.fiatTracking"
|
||||
:options="receive.units.filter((u) => u !== 'sat')"
|
||||
:label="$t('currency_settings')"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-4 q-pl-sm">
|
||||
<q-btn
|
||||
dense
|
||||
color="primary"
|
||||
class="q-mt-xs full-width"
|
||||
@click="handleFiatTracking()"
|
||||
:disable="update.currency == ''"
|
||||
:label="g.fiatTracking ? 'Remove' : 'Add'"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
class="float-right q-mb-lg"
|
||||
to="/admin#exchange_providers"
|
||||
><q-tooltip
|
||||
v-text="$t('exchange_providers')"
|
||||
></q-tooltip
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<p v-text="$t('delete_wallet_desc')"></p>
|
||||
</div>
|
||||
<div class="col-4 q-pl-sm">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
class="full-width"
|
||||
@click="deleteWallet()"
|
||||
:label="$t('delete_wallet')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-2"></div>
|
||||
</div>
|
||||
<q-btn
|
||||
:disable="!update.name.length"
|
||||
unelevated
|
||||
class="q-mt-sm"
|
||||
color="primary"
|
||||
:label="$t('update_currency')"
|
||||
@click="updateWallet({ currency: update.currency || '' })"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="charts"
|
||||
icon="insights"
|
||||
:label="$t('wallet_charts')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartConfig.showBalance"
|
||||
:label="$t('payments_balance_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
:label="$t('delete_wallet')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p v-text="$t('delete_wallet_desc')"></p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet()"
|
||||
:label="$t('delete_wallet')"
|
||||
></q-btn>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartConfig.showBalanceInOut"
|
||||
:label="$t('payments_balance_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartConfig.showPaymentCountInOut"
|
||||
:label="$t('payments_count_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -276,7 +475,6 @@
|
||||
style="display: inline-block"
|
||||
href="{{ AD[0] }}"
|
||||
class="q-ml-md q-mb-xs q-mr-md"
|
||||
style="max-width: 80%"
|
||||
>
|
||||
<img
|
||||
style="max-width: 100%; height: auto"
|
||||
@@ -292,9 +490,84 @@
|
||||
>{% endfor %}
|
||||
</q-card>
|
||||
{% endif %}
|
||||
<div
|
||||
v-show="chartDataPointCount"
|
||||
class="col-12 col-md-5 q-gutter-y-md"
|
||||
>
|
||||
<q-card v-if="chartConfig.showBalance">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div style="height: 200px" class="q-pa-sm">
|
||||
<canvas ref="walletBalanceChart"></canvas>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card v-if="chartConfig.showBalanceInOut">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div style="height: 200px" class="q-pa-sm">
|
||||
<canvas ref="walletBalanceInOut"></canvas>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card v-if="chartConfig.showPaymentCountInOut">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div style="height: 200px" class="q-pa-sm">
|
||||
<canvas ref="walletPaymentsInOut"></canvas>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-if="hasChartActive && !chartDataPointCount">
|
||||
<q-card>
|
||||
<q-card-section> No chart data available</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="icon.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="setIcon" class="q-gutter-md">
|
||||
<div class="q-gutter-sm q-pa-sm flex flex-wrap justify-center">
|
||||
<!-- Loop through all icons -->
|
||||
<q-btn
|
||||
v-for="(thisIcon, index) in icon.options"
|
||||
:key="index"
|
||||
@click="setSelectedIcon(thisIcon)"
|
||||
round
|
||||
text-color="black"
|
||||
:color="icon.data.icon === thisIcon ? icon.data.color || 'primary' : 'grey-5'"
|
||||
size="md"
|
||||
:icon="thisIcon"
|
||||
class="q-mb-sm"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="q-pa-sm flex justify-between items-center">
|
||||
<div class="flex q-pl-lg">
|
||||
<!-- Color options -->
|
||||
<q-btn
|
||||
v-for="(color, index) in icon.colorOptions"
|
||||
:key="'color-' + index"
|
||||
@click="setSelectedColor(color)"
|
||||
round
|
||||
:color="color"
|
||||
size="xs"
|
||||
style="width: 24px; height: 24px; min-width: 24px; padding: 0"
|
||||
class="q-mr-xs"
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="!icon.data.icon"
|
||||
type="submit"
|
||||
>
|
||||
Save Icon
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog
|
||||
v-model="receive.show"
|
||||
position="top"
|
||||
@@ -312,7 +585,7 @@
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
v-model="receive.data.amount"
|
||||
:label="$t('amount')+' ({{LNBITS_DENOMINATION}}) *'"
|
||||
mask="#.##"
|
||||
fill-mask="0"
|
||||
@@ -371,11 +644,11 @@
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
<q-spinner
|
||||
<q-spinner-bars
|
||||
v-if="receive.status == 'loading'"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner>
|
||||
></q-spinner-bars>
|
||||
</q-form>
|
||||
</q-card>
|
||||
<q-card
|
||||
@@ -384,9 +657,7 @@
|
||||
>
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<lnbits-qrcode
|
||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
||||
></lnbits-qrcode>
|
||||
<div v-html="invoiceQrCode"></div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
@@ -402,9 +673,13 @@
|
||||
color="positive"
|
||||
text-color="white"
|
||||
></q-avatar>
|
||||
NFC supported
|
||||
<span v-text="$t('nfc_supported')"></span>
|
||||
</q-chip>
|
||||
<span v-else class="text-caption text-grey">NFC not supported</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('nfc_not_supported')"
|
||||
></span>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
@@ -427,27 +702,131 @@
|
||||
<q-dialog v-model="parse.show" @hide="closeParseDialog" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
<h6
|
||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||
class="q-my-none"
|
||||
v-text="parseFloat(String(parse.invoice.fsat).replaceAll(',', '')) / 100 + '{{LNBITS_DENOMINATION}}'"
|
||||
></h6>
|
||||
<h6
|
||||
v-else
|
||||
class="q-my-none"
|
||||
v-text="parse.invoice.fsat + '{{LNBITS_DENOMINATION}}'"
|
||||
></h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong v-text="$t('memo') + ': '"></strong>
|
||||
<span v-text="parse.invoice.description"></span>
|
||||
<br />
|
||||
<strong>Expire date: </strong>
|
||||
<span v-text="parse.invoice.expireDate"></span>
|
||||
<br />
|
||||
<strong>Hash: </strong>
|
||||
<span v-text="parse.invoice.hash"></span>
|
||||
</p>
|
||||
<div class="column content-center text-center q-mb-md">
|
||||
<div v-if="!isFiatPriority">
|
||||
<h4 class="q-my-none text-bold">
|
||||
<span v-text="walletFormatBalance(parse.invoice.sat)"></span>
|
||||
</h4>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h4
|
||||
class="q-my-none text-bold"
|
||||
v-text="parse.invoice.fiatAmount"
|
||||
></h4>
|
||||
</div>
|
||||
<div class="q-my-md absolute">
|
||||
<q-btn
|
||||
v-if="g.fiatTracking"
|
||||
@click="swapBalancePriority"
|
||||
flat
|
||||
dense
|
||||
icon="swap_vert"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div v-if="g.fiatTracking">
|
||||
<div v-if="isFiatPriority">
|
||||
<div class="text-h5 text-italic">
|
||||
<span
|
||||
v-text="parse.invoice.fiatAmount"
|
||||
style="opacity: 0.75"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="opacity: 0.75">
|
||||
<h5 class="q-my-none text-bold">
|
||||
<span
|
||||
v-text="walletFormatBalance(parse.invoice.sat)"
|
||||
></span>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator></q-separator>
|
||||
<h6 class="text-center" v-text="parse.invoice.description"></h6>
|
||||
<q-list separator bordered dense class="q-mb-md">
|
||||
<q-expansion-item expand-separator icon="info" label="Details">
|
||||
<q-list separator>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('created')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="parse.invoice.createdDate"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side top>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="parse.invoice.createdDateFrom"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('expire_date')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="parse.invoice.expireDate"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side top>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="parse.invoice.expireDateFrom"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('payment_hash')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="`${parse.invoice.hash.slice(0, 12)}...${parse.invoice.hash.slice(-12)}`"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(parse.invoice.hash)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</q-item-label>
|
||||
<q-tooltip>
|
||||
<span v-text="parse.invoice.hash"></span>
|
||||
</q-tooltip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="$t('Invoice')"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="`${parse.invoice.bolt11.slice(0, 12)}...${parse.invoice.bolt11.slice(-12)}`"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
@click="copyText(parse.invoice.bolt11)"
|
||||
size="1em"
|
||||
color="grey"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</q-item-label>
|
||||
<q-tooltip>
|
||||
<span v-text="parse.invoice.bolt11"></span>
|
||||
</q-tooltip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<q-tab-panel name="channels">
|
||||
<q-dialog v-model="connectPeerDialog.show">
|
||||
<q-dialog v-model="connectPeerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
@@ -29,6 +29,45 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="setFeeDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<label class="text-h6">Set Channel Fee</label>
|
||||
<p class="text-caption" v-text="setFeeDialog.channel_id"></p>
|
||||
<q-separator></q-separator>
|
||||
<q-form class="q-gutter-md">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="setFeeDialog.data.fee_ppm"
|
||||
label="Fee Rate PPM"
|
||||
></q-input>
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model.number="setFeeDialog.data.fee_base_msat"
|
||||
label="Fee Base msat"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('set')"
|
||||
color="primary"
|
||||
@click="setChannelFee(setFeeDialog.channel_id)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="openChannelDialog.show">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
@@ -89,7 +128,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="closeChannelDialog.show">
|
||||
<q-dialog v-model="closeChannelDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<div>
|
||||
@@ -171,10 +210,41 @@
|
||||
<q-tr :props="props">
|
||||
<div class="q-pb-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<div
|
||||
class="text-subtitle1 col-grow"
|
||||
v-text="props.row.name"
|
||||
></div>
|
||||
<div class="text-subtitle1" v-text="props.row.name"></div>
|
||||
<div class="text-caption" v-if="props.row.peer_id">
|
||||
<span>Peer ID</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.peer_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption col-grow">
|
||||
<span>Fees</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="settings"
|
||||
@click="showSetFeeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
<span v-if="props.row.fee_ppm">
|
||||
<span v-text="props.row.fee_ppm"></span> ppm,
|
||||
<span v-text="props.row.fee_base_msat"></span> msat
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.id">
|
||||
<span>Channel ID</span>
|
||||
<q-btn
|
||||
size="xs"
|
||||
flat
|
||||
dense
|
||||
icon="content_paste"
|
||||
@click="copyText(props.row.id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="text-caption" v-if="props.row.short_id">
|
||||
<span v-text="props.row.short_id"></span>
|
||||
<q-btn
|
||||
@@ -201,6 +271,7 @@
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<lnbits-channel-balance
|
||||
:balance="props.row.balance"
|
||||
:color="props.row.color"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<q-tab-panel name="transactions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-dialog v-model="transactionDetailsDialog.show">
|
||||
<q-dialog v-model="transactionDetailsDialog.show" position="top">
|
||||
<q-card class="my-card">
|
||||
<q-card-section>
|
||||
<div class="text-center q-mb-lg">
|
||||
@@ -120,7 +120,11 @@
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
<q-dialog v-model="props.row.expand" :props="props">
|
||||
<q-dialog
|
||||
v-model="props.row.expand"
|
||||
:props="props"
|
||||
position="top"
|
||||
>
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
{% if not ajax %} {% extends "base.html" %} {% endif %}
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
|
||||
|
||||
<q-dialog v-model="nodeInfoDialog.show">
|
||||
<q-dialog v-model="nodeInfoDialog.show" position="top">
|
||||
<lnbits-node-qrcode :info="nodeInfoDialog.data"></lnbits-node-qrcode>
|
||||
</q-dialog>
|
||||
|
||||
@@ -40,336 +43,4 @@
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
window.app = Vue.createApp({
|
||||
el: '#vue',
|
||||
config: {
|
||||
globalProperties: {
|
||||
LNbits,
|
||||
msg: 'hello'
|
||||
}
|
||||
},
|
||||
mixins: [window.windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
tab: 'dashboard',
|
||||
payments: 1000,
|
||||
info: {},
|
||||
channel_stats: {},
|
||||
|
||||
channels: {
|
||||
data: [],
|
||||
filter: ''
|
||||
},
|
||||
|
||||
activeBalance: {},
|
||||
ranks: {},
|
||||
|
||||
peers: {
|
||||
data: [],
|
||||
filter: ''
|
||||
},
|
||||
|
||||
connectPeerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
openChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
closeChannelDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
nodeInfoDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
transactionDetailsDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
|
||||
states: [
|
||||
{label: 'Active', value: 'active', color: 'green'},
|
||||
{label: 'Pending', value: 'pending', color: 'orange'},
|
||||
{label: 'Inactive', value: 'inactive', color: 'grey'},
|
||||
{label: 'Closed', value: 'closed', color: 'red'}
|
||||
],
|
||||
|
||||
stateFilters: [
|
||||
{label: 'Active', value: 'active'},
|
||||
{label: 'Pending', value: 'pending'}
|
||||
],
|
||||
|
||||
paymentsTable: {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: this.$t('date'),
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'sat',
|
||||
align: 'right',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: row => this.formatMsat(row.amount),
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
|
||||
field: 'fee'
|
||||
},
|
||||
{
|
||||
name: 'destination',
|
||||
align: 'right',
|
||||
label: 'Destination',
|
||||
field: 'destination'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
},
|
||||
invoiceTable: {
|
||||
data: [],
|
||||
columns: [
|
||||
{
|
||||
name: 'pending',
|
||||
label: ''
|
||||
},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: this.$t('memo'),
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'paid_at',
|
||||
field: 'paid_at',
|
||||
align: 'right',
|
||||
label: 'Paid at',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'expiry',
|
||||
label: this.$t('expiry'),
|
||||
field: 'expiry',
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
|
||||
field: row => this.formatMsat(row.amount),
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10,
|
||||
page: 1,
|
||||
rowsNumber: 10
|
||||
},
|
||||
filter: null
|
||||
}
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getInfo()
|
||||
this.get1MLStats()
|
||||
},
|
||||
watch: {
|
||||
tab: function (val) {
|
||||
if (val === 'transactions' && !this.paymentsTable.data.length) {
|
||||
this.getPayments()
|
||||
this.getInvoices()
|
||||
} else if (val === 'channels' && !this.channels.data.length) {
|
||||
this.getChannels()
|
||||
this.getPeers()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
},
|
||||
filteredChannels: function () {
|
||||
return this.stateFilters
|
||||
? this.channels.data.filter(channel => {
|
||||
return this.stateFilters.find(({value}) => value == channel.state)
|
||||
})
|
||||
: this.channels.data
|
||||
},
|
||||
totalBalance: function () {
|
||||
return this.filteredChannels.reduce(
|
||||
(balance, channel) => {
|
||||
balance.local_msat += channel.balance.local_msat
|
||||
balance.remote_msat += channel.balance.remote_msat
|
||||
balance.total_msat += channel.balance.total_msat
|
||||
return balance
|
||||
},
|
||||
{local_msat: 0, remote_msat: 0, total_msat: 0}
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatMsat: function (msat) {
|
||||
return LNbits.utils.formatMsat(msat)
|
||||
},
|
||||
api: function (method, url, options) {
|
||||
const params = new URLSearchParams(options?.query)
|
||||
return LNbits.api
|
||||
.request(method, `/node/api/v1${url}?${params}`, {}, options?.data)
|
||||
.catch(error => {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getChannels: function () {
|
||||
return this.api('GET', '/channels').then(response => {
|
||||
this.channels.data = response.data
|
||||
})
|
||||
},
|
||||
getInfo: function () {
|
||||
return this.api('GET', '/info').then(response => {
|
||||
this.info = response.data
|
||||
this.channel_stats = response.data.channel_stats
|
||||
})
|
||||
},
|
||||
get1MLStats: function () {
|
||||
return this.api('GET', '/rank').then(response => {
|
||||
this.ranks = response.data
|
||||
})
|
||||
},
|
||||
getPayments: function (props) {
|
||||
if (props) {
|
||||
this.paymentsTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.paymentsTable.pagination
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage ?? 0
|
||||
}
|
||||
return this.api('GET', '/payments', {query}).then(response => {
|
||||
this.paymentsTable.data = response.data.data
|
||||
this.paymentsTable.pagination.rowsNumber = response.data.total
|
||||
})
|
||||
},
|
||||
getInvoices: function (props) {
|
||||
if (props) {
|
||||
this.invoiceTable.pagination = props.pagination
|
||||
}
|
||||
let pagination = this.invoiceTable.pagination
|
||||
const query = {
|
||||
limit: pagination.rowsPerPage,
|
||||
offset: (pagination.page - 1) * pagination.rowsPerPage ?? 0
|
||||
}
|
||||
return this.api('GET', '/invoices', {query}).then(response => {
|
||||
this.invoiceTable.data = response.data.data
|
||||
this.invoiceTable.pagination.rowsNumber = response.data.total
|
||||
})
|
||||
},
|
||||
getPeers: function () {
|
||||
return this.api('GET', '/peers').then(response => {
|
||||
this.peers.data = response.data
|
||||
console.log('peers', this.peers)
|
||||
})
|
||||
},
|
||||
connectPeer: function () {
|
||||
console.log('peer', this.connectPeerDialog)
|
||||
this.api('POST', '/peers', {data: this.connectPeerDialog.data}).then(
|
||||
() => {
|
||||
this.connectPeerDialog.show = false
|
||||
this.getPeers()
|
||||
}
|
||||
)
|
||||
},
|
||||
disconnectPeer: function (id) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Do you really wanna disconnect this peer?')
|
||||
.onOk(() => {
|
||||
this.api('DELETE', `/peers/${id}`).then(response => {
|
||||
Quasar.Notify.create({
|
||||
message: 'Disconnected',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
this.getPeers()
|
||||
})
|
||||
})
|
||||
},
|
||||
openChannel: function () {
|
||||
this.api('POST', '/channels', {data: this.openChannelDialog.data})
|
||||
.then(response => {
|
||||
this.openChannelDialog.show = false
|
||||
this.getChannels()
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error)
|
||||
})
|
||||
},
|
||||
showCloseChannelDialog: function (channel) {
|
||||
this.closeChannelDialog.show = true
|
||||
this.closeChannelDialog.data = {
|
||||
force: false,
|
||||
short_id: channel.short_id,
|
||||
...channel.point
|
||||
}
|
||||
},
|
||||
closeChannel: function () {
|
||||
this.api('DELETE', '/channels', {
|
||||
query: this.closeChannelDialog.data
|
||||
}).then(response => {
|
||||
this.closeChannelDialog.show = false
|
||||
this.getChannels()
|
||||
})
|
||||
},
|
||||
showOpenChannelDialog: function (peer_id) {
|
||||
this.openChannelDialog.show = true
|
||||
this.openChannelDialog.data = {peer_id, funding_amount: 0}
|
||||
},
|
||||
showNodeInfoDialog: function (node) {
|
||||
this.nodeInfoDialog.show = true
|
||||
this.nodeInfoDialog.data = node
|
||||
},
|
||||
showTransactionDetailsDialog: function (details) {
|
||||
this.transactionDetailsDialog.show = true
|
||||
this.transactionDetailsDialog.data = details
|
||||
console.log('details', details)
|
||||
},
|
||||
shortenNodeId(nodeId) {
|
||||
return nodeId
|
||||
? nodeId.substring(0, 5) + '...' + nodeId.substring(nodeId.length - 5)
|
||||
: '...'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<script src="{{ static_url_for('static', 'js/node.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
{% 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"></div>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentStatus"
|
||||
:label="$t('payments_status_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentTags"
|
||||
:label="$t('payments_tag_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showBalance"
|
||||
:label="$t('payments_balance_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showWalletsSize"
|
||||
:label="$t('payments_wallets_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showBalanceInOut"
|
||||
:label="$t('payments_balance_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div class="float-left">
|
||||
<q-checkbox
|
||||
dense
|
||||
@click="saveChartsPreferences"
|
||||
v-model="chartData.showPaymentCountInOut"
|
||||
:label="$t('payments_count_in_out_chart')"
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<q-btn icon="event" outline flat>
|
||||
<q-popup-proxy
|
||||
cover
|
||||
transition-show="scale"
|
||||
transition-hide="scale"
|
||||
>
|
||||
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
label="Search"
|
||||
@click="searchByDate()"
|
||||
color="primary"
|
||||
flat
|
||||
class="float-left"
|
||||
v-close-popup
|
||||
/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@click="clearDateSeach()"
|
||||
label="Clear"
|
||||
class="float-right"
|
||||
color="grey"
|
||||
flat
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
<q-badge
|
||||
v-if="searchDate?.to || searchDate?.from"
|
||||
class="q-mt-lg q-mr-md"
|
||||
color="primary"
|
||||
rounded
|
||||
floating
|
||||
style="border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-separator vertical class="q-ma-sm"></q-separator>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#server"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!showDetails">
|
||||
<div class="row q-col-gutter-md justify-center q-mb-md">
|
||||
<div
|
||||
v-show="chartData.showPaymentStatus"
|
||||
class="col-lg-3 col-md-6 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_status')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsStatusChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showPaymentStatus"
|
||||
class="col-lg-3 col-md-6 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_tags')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsTagsChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showBalance"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong
|
||||
v-text="$t('lnbits_balance', {balance: (lnbitsBalance || 0).toLocaleString()})"
|
||||
></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsDailyChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showWalletsSize"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payment_chart_tx_per_wallet')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsWalletsChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="chartData.showBalanceInOut"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payments_balance_in_out')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsBalanceInOutChart"></canvas>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
<div
|
||||
v-show="chartData.showPaymentCountInOut"
|
||||
class="col-lg-6 col-md-12 col-sm-12 text-center"
|
||||
>
|
||||
<q-card class="q-pt-sm">
|
||||
<strong v-text="$t('payments_count_in_out')"></strong>
|
||||
<div style="height: 300px" class="q-pa-sm">
|
||||
<canvas v-if="chartsReady" ref="paymentsCountInOutChart"></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="payment_hash"
|
||||
:rows="payments"
|
||||
:columns="paymentsTable.columns"
|
||||
v-model:pagination="paymentsTable.pagination"
|
||||
:filter="paymentsTable.search"
|
||||
@request="fetchPayments"
|
||||
>
|
||||
<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="['wallet_id', 'payment_hash', 'memo'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
@keydown.enter="searchPaymentsBy()"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
clearable
|
||||
:label="col.label"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
name="search"
|
||||
@click="searchPaymentsBy()"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
<q-select
|
||||
v-else-if="['status'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
:label="col.label"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
></q-select>
|
||||
<q-select
|
||||
v-else-if="['tag'].includes(col.name)"
|
||||
v-model="searchData[col.name]"
|
||||
:options="searchOptions[col.name]"
|
||||
@update:model-value="searchPaymentsBy()"
|
||||
: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 == 'status'">
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_details')"></span
|
||||
></q-tooltip>
|
||||
<q-icon
|
||||
@click="showDetailsToggle(props.row)"
|
||||
v-if="props.row.status === 'success'"
|
||||
size="14px"
|
||||
:name="props.row.outgoing ? 'call_made' : 'call_received'"
|
||||
:color="props.row.outgoing ? 'pink' : 'green'"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else-if="props.row.status === 'pending'"
|
||||
@click="showDetailsToggle(props.row)"
|
||||
name="downloading"
|
||||
:style="props.row.outgoing ? 'transform: rotate(225deg)' : 'transform: scaleX(-1) rotate(315deg)'"
|
||||
color="grey"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
@click="showDetailsToggle(props.row)"
|
||||
name="warning"
|
||||
color="yellow"
|
||||
class="cursor-pointer"
|
||||
></q-icon>
|
||||
</div>
|
||||
<div v-else-if="col.name == 'created_at'">
|
||||
<div>
|
||||
<q-tooltip anchor="top middle">
|
||||
<span v-text="formatDate(props.row.created_at)"></span>
|
||||
</q-tooltip>
|
||||
<span v-text="props.row.timeFrom"> </span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="['wallet_id', 'payment_hash', 'memo'].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 anchor="top middle">Copy</q-tooltip>
|
||||
</q-btn>
|
||||
<span v-text="shortify(props.row[col.name], col.max_length)">
|
||||
</span>
|
||||
<q-tooltip>
|
||||
<span v-text="props.row[col.name]"></span>
|
||||
</q-tooltip>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
v-text="props.row[col.name]"
|
||||
class="cursor-pointer"
|
||||
></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showDetails">
|
||||
<q-card>
|
||||
<q-card-section class="flex">
|
||||
<div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
class="q-mr-md"
|
||||
@click="showDetailsToggle(null)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="self-center text-h6 text-weight-bolder text-grey-5">
|
||||
<span v-text="$t('payment_details_back')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section class="text-h6">
|
||||
<q-item>
|
||||
<q-item-section avatar class="">
|
||||
<q-icon color="primary" name="receipt" size="44px"></q-icon>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('payment_details')"></span>
|
||||
</div>
|
||||
</q-item-label>
|
||||
<q-item-label caption v-text="$t('payment_details_desc')">
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-list separator>
|
||||
<q-item v-for="(value, key) in paymentDetails" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label v-text="key"></q-item-label>
|
||||
<q-item-label
|
||||
caption
|
||||
v-text="value"
|
||||
style="word-wrap: break-word"
|
||||
></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
v-show="value"
|
||||
icon="content_copy"
|
||||
flat
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(value)"
|
||||
>
|
||||
<q-tooltip>Copy</q-tooltip>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
<!-- <q-separator></q-separator> -->
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -30,9 +30,6 @@ self.addEventListener('activate', evt =>
|
||||
// from the network before returning it to the page.
|
||||
self.addEventListener('fetch', event => {
|
||||
if (
|
||||
!event.request.url.startsWith(
|
||||
self.location.origin + '/api/v1/payments/sse'
|
||||
) &&
|
||||
event.request.url.startsWith(self.location.origin) &&
|
||||
event.request.method == 'GET'
|
||||
) {
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<q-table :rows="wallets" :columns="walletTable.columns">
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width v-if="g.user.super_user"></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
auto-width
|
||||
@@ -47,14 +48,14 @@
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
:label="$t('topup')"
|
||||
@click="showTopupDialog(props.row.id)"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
<q-td auto-width v-if="g.user.super_user">
|
||||
<lnbits-update-balance
|
||||
:wallet_id="props.row.id"
|
||||
@credit-value="handleBalanceUpdate"
|
||||
class="q-mr-md"
|
||||
></q-btn>
|
||||
></lnbits-update-balance>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
round
|
||||
icon="menu"
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<q-dialog 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="row q-mt-lg">
|
||||
<q-btn
|
||||
:label="$t('topup')"
|
||||
color="primary"
|
||||
@click="topupWallet"
|
||||
v-close-popup
|
||||
></q-btn>
|
||||
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
:label="$t('cancel')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
@@ -1,5 +1,8 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}{% include "users/_topupDialog.html" %}
|
||||
{% 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 justify-center">
|
||||
<div class="col">
|
||||
@@ -11,13 +14,35 @@
|
||||
{%include "users/_createWalletDialog.html" %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-btn
|
||||
@click="showAccountPage()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-btn>
|
||||
<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
|
||||
@click="showAccountPage()"
|
||||
:label="$t('create_account')"
|
||||
color="primary"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
round
|
||||
icon="settings"
|
||||
to="/admin#users"
|
||||
>
|
||||
<q-tooltip v-text="$t('admin_settings')"></q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card class="q-pa-md">
|
||||
<q-table
|
||||
@@ -148,6 +173,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="{{ static_url_for('static', 'js/users.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,14 +10,16 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services import (
|
||||
enqueue_notification,
|
||||
get_balance_delta,
|
||||
update_cached_settings,
|
||||
)
|
||||
from lnbits.core.tasks import api_invoice_listeners
|
||||
from lnbits.core.services.settings import dict_to_settings
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, UpdateSettings, settings
|
||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
@@ -45,7 +47,6 @@ async def api_auditor():
|
||||
async def api_monitor():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
"api_invoice_listeners": list(api_invoice_listeners.keys()),
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +63,7 @@ async def api_get_settings(
|
||||
status_code=HTTPStatus.OK,
|
||||
)
|
||||
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
await update_admin_settings(data)
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
assert admin_settings, "Updated admin settings not found."
|
||||
@@ -70,12 +72,28 @@ async def api_update_settings(data: UpdateSettings, user: User = Depends(check_a
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
@admin_router.patch(
|
||||
"/api/v1/settings",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_delete_settings() -> None:
|
||||
async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
|
||||
updatable_settings = dict_to_settings({**settings.dict(), **data})
|
||||
return await api_update_settings(updatable_settings, user)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/v1/settings/default",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_reset_settings(field_name: str):
|
||||
default_settings = Settings()
|
||||
return {"default_value": getattr(default_settings, field_name)}
|
||||
|
||||
|
||||
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
|
||||
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
|
||||
enqueue_notification(NotificationType.settings_update, {"username": user.username})
|
||||
await delete_admin_settings()
|
||||
server_restart.set()
|
||||
|
||||
|
||||
+17
-11
@@ -15,7 +15,6 @@ from fastapi import (
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.core.models import (
|
||||
BaseWallet,
|
||||
ConversionData,
|
||||
@@ -30,12 +29,13 @@ from lnbits.decorators import (
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
get_fiat_rate_satoshis,
|
||||
get_fiat_rate_and_price_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from lnbits.wallets import get_funding_source
|
||||
@@ -50,21 +50,18 @@ api_router = APIRouter(tags=["Core"])
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"server_time": int(time()),
|
||||
"up_time": int(time() - settings.server_startup_time),
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
|
||||
async def health_check(wallet: WalletTypeInfo = Depends(require_invoice_key)) -> dict:
|
||||
async def health_check(user: User = Depends(check_user_exists)) -> dict:
|
||||
stat: dict[str, Any] = {
|
||||
"server_time": int(time()),
|
||||
"up_time": int(time() - settings.server_startup_time),
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
"up_time_seconds": int(time() - settings.server_startup_time),
|
||||
}
|
||||
|
||||
user = await get_user(wallet.wallet.user)
|
||||
if not user:
|
||||
return stat
|
||||
|
||||
stat["version"] = settings.version
|
||||
if not user.admin:
|
||||
return stat
|
||||
@@ -132,6 +129,7 @@ async def api_lnurlscan(
|
||||
else:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
check_callback_url(url)
|
||||
r = await client.get(url, timeout=5)
|
||||
r.raise_for_status()
|
||||
if r.is_error:
|
||||
@@ -225,10 +223,18 @@ async def api_perform_lnurlauth(
|
||||
return ""
|
||||
|
||||
|
||||
@api_router.get(
|
||||
"/api/v1/rate/history",
|
||||
dependencies=[Depends(check_user_exists)],
|
||||
)
|
||||
async def api_exchange_rate_history() -> list[dict]:
|
||||
return settings.lnbits_exchange_rate_history
|
||||
|
||||
|
||||
@api_router.get("/api/v1/rate/{currency}")
|
||||
async def api_check_fiat_rate(currency: str) -> dict[str, float]:
|
||||
rate = await get_fiat_rate_satoshis(currency)
|
||||
return {"rate": rate}
|
||||
rate, price = await get_fiat_rate_and_price_satoshis(currency)
|
||||
return {"rate": rate, "price": price}
|
||||
|
||||
|
||||
@api_router.get("/api/v1/currencies")
|
||||
|
||||
@@ -11,12 +11,26 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.users import (
|
||||
get_user_access_control_lists,
|
||||
update_user_access_control_list,
|
||||
)
|
||||
from lnbits.core.models.misc import SimpleItem
|
||||
from lnbits.core.models.users import (
|
||||
ApiTokenRequest,
|
||||
ApiTokenResponse,
|
||||
DeleteAccessControlList,
|
||||
DeleteTokenRequest,
|
||||
EndpointAccess,
|
||||
UpdateAccessControlList,
|
||||
)
|
||||
from lnbits.core.services import create_user_account
|
||||
from lnbits.decorators import access_token_payload, check_user_exists
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
decrypt_internal_message,
|
||||
encrypt_internal_message,
|
||||
get_api_routes,
|
||||
is_valid_email_address,
|
||||
is_valid_username,
|
||||
)
|
||||
@@ -44,6 +58,7 @@ from ..models import (
|
||||
UpdateUserPassword,
|
||||
UpdateUserPubkey,
|
||||
User,
|
||||
UserAcls,
|
||||
UserExtra,
|
||||
)
|
||||
|
||||
@@ -95,9 +110,131 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
|
||||
account = await get_account(data.usr)
|
||||
if not account:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
|
||||
if account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Admin users cannot login with user id only."
|
||||
)
|
||||
return _auth_success_response(account.username, account.id, account.email)
|
||||
|
||||
|
||||
@auth_router.get("/acl")
|
||||
async def api_get_user_acls(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> UserAcls:
|
||||
api_routes = get_api_routes(request.app.router.routes)
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
|
||||
# Add missing/new endpoints to the ACLs
|
||||
for acl in acls.access_control_list:
|
||||
acl_api_routes = {**api_routes}
|
||||
for route in api_routes.keys():
|
||||
if acl.get_endpoint(route):
|
||||
acl_api_routes.pop(route, None)
|
||||
|
||||
for path, name in acl_api_routes.items():
|
||||
acl.endpoints.append(EndpointAccess(path=path, name=name))
|
||||
acl.endpoints.sort(key=lambda e: e.name.lower())
|
||||
|
||||
return UserAcls(id=user.id, access_control_list=acls.access_control_list)
|
||||
|
||||
|
||||
@auth_router.put("/acl")
|
||||
@auth_router.patch("/acl")
|
||||
async def api_update_user_acl(
|
||||
request: Request,
|
||||
data: UpdateAccessControlList,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> UserAcls:
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
user_acls = await get_user_access_control_lists(user.id)
|
||||
acl = user_acls.get_acl_by_id(data.id)
|
||||
if acl:
|
||||
user_acls.access_control_list.remove(acl)
|
||||
else:
|
||||
data.endpoints = []
|
||||
data.id = uuid4().hex
|
||||
|
||||
api_routes = get_api_routes(request.app.router.routes)
|
||||
for path, name in api_routes.items():
|
||||
data.endpoints.append(EndpointAccess(path=path, name=name))
|
||||
|
||||
api_paths = get_api_routes(request.app.router.routes).keys()
|
||||
data.endpoints = [e for e in data.endpoints if e.path in api_paths]
|
||||
data.endpoints.sort(key=lambda e: e.name.lower())
|
||||
|
||||
user_acls.access_control_list.append(data)
|
||||
user_acls.access_control_list.sort(key=lambda t: t.name.lower())
|
||||
await update_user_access_control_list(user_acls)
|
||||
|
||||
return user_acls
|
||||
|
||||
|
||||
@auth_router.delete("/acl")
|
||||
async def api_delete_user_acl(
|
||||
data: DeleteAccessControlList,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
user_acls = await get_user_access_control_lists(user.id)
|
||||
user_acls.delete_acl_by_id(data.id)
|
||||
await update_user_access_control_list(user_acls)
|
||||
|
||||
|
||||
@auth_router.post("/acl/token")
|
||||
async def api_create_user_api_token(
|
||||
data: ApiTokenRequest,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> ApiTokenResponse:
|
||||
assert data.expiration_time_minutes > 0, "Expiration time must be in the future."
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
assert account.username, "Username must be configured."
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||
|
||||
api_token_id = uuid4().hex
|
||||
api_token = _auth_api_token_response(
|
||||
account.username, api_token_id, data.expiration_time_minutes
|
||||
)
|
||||
|
||||
acl.token_id_list.append(SimpleItem(id=api_token_id, name=data.token_name))
|
||||
await update_user_access_control_list(acls)
|
||||
return ApiTokenResponse(id=api_token_id, api_token=api_token)
|
||||
|
||||
|
||||
@auth_router.delete("/acl/token")
|
||||
async def api_delete_user_api_token(
|
||||
data: DeleteTokenRequest,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
|
||||
account = await get_account(user.id)
|
||||
if not account or not account.verify_password(data.password):
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
|
||||
|
||||
assert account.username, "Username must be configured."
|
||||
|
||||
acls = await get_user_access_control_lists(user.id)
|
||||
acl = acls.get_acl_by_id(data.acl_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
|
||||
acl.delete_token_by_id(data.id)
|
||||
await update_user_access_control_list(acls)
|
||||
|
||||
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request, provider: str, user_id: Optional[str] = None
|
||||
@@ -370,6 +507,17 @@ def _auth_success_response(
|
||||
return response
|
||||
|
||||
|
||||
def _auth_api_token_response(
|
||||
username: str, api_token_id: str, token_expire_minutes: int
|
||||
):
|
||||
payload = AccessTokenPayload(
|
||||
sub=username, api_token_id=api_token_id, auth_time=int(time())
|
||||
)
|
||||
return create_access_token(
|
||||
data=payload.dict(), token_expire_minutes=token_expire_minutes
|
||||
)
|
||||
|
||||
|
||||
def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
|
||||
payload = AccessTokenPayload(sub="" or "", email=email, auth_time=int(time()))
|
||||
access_token = create_access_token(data=payload.dict())
|
||||
|
||||
@@ -69,11 +69,6 @@ async def api_install_extension(data: CreateExtension):
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
||||
)
|
||||
|
||||
if not release.is_version_compatible:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||
)
|
||||
|
||||
release.payment_hash = data.payment_hash
|
||||
ext_meta = ExtensionMeta(installed_release=release)
|
||||
ext_info = InstallableExtension(
|
||||
|
||||
@@ -17,7 +17,7 @@ from lnbits.core.models.extensions import ExtensionMeta, InstallableExtension
|
||||
from lnbits.core.services import create_invoice, create_user_account
|
||||
from lnbits.core.services.extensions import get_valid_extensions
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.helpers import check_callback_url, template_renderer
|
||||
from lnbits.settings import settings
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
@@ -50,12 +50,9 @@ async def home(request: Request, lightning: str = ""):
|
||||
@generic_router.get("/first_install", response_class=HTMLResponse)
|
||||
async def first_install(request: Request):
|
||||
if not settings.first_install:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"err": "Super user account has already been configured.",
|
||||
},
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Super user account has already been configured.",
|
||||
)
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
@@ -107,7 +104,7 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
|
||||
db_versions = await get_db_versions()
|
||||
|
||||
extensions = [
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
@@ -152,7 +149,8 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
"core/extensions.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"extensions": extensions,
|
||||
"extension_data": extension_data,
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -179,25 +177,25 @@ async def wallet(
|
||||
wallet = user.wallets[0]
|
||||
|
||||
if not wallet or wallet.deleted:
|
||||
return template_renderer().TemplateResponse(
|
||||
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Wallet not found",
|
||||
)
|
||||
context = {
|
||||
"user": user.json(),
|
||||
"wallet": wallet.json(),
|
||||
"wallet_name": wallet.name,
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
}
|
||||
|
||||
resp = template_renderer().TemplateResponse(
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"core/wallet.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"wallet": wallet.json(),
|
||||
"wallet_name": wallet.name,
|
||||
"currencies": allowed_currencies(),
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"service_fee_max": settings.lnbits_service_fee_max,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest",
|
||||
},
|
||||
{**context, "ajax": _is_ajax_request(request)},
|
||||
)
|
||||
resp.set_cookie("lnbits_last_active_wallet", wallet.id)
|
||||
return resp
|
||||
|
||||
|
||||
@generic_router.get(
|
||||
@@ -214,6 +212,7 @@ async def account(
|
||||
"core/account.html",
|
||||
{
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -327,6 +326,7 @@ async def node(request: Request, user: User = Depends(check_admin)):
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"wallets": user.wallets[0].json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -365,6 +365,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
"currencies": list(currencies.keys()),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -381,6 +382,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
|
||||
"user": user.json(),
|
||||
"settings": settings.dict(),
|
||||
"currencies": list(currencies.keys()),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -395,6 +397,19 @@ async def audit_index(request: Request, user: User = Depends(check_admin)):
|
||||
{
|
||||
"request": request,
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@generic_router.get("/payments", response_class=HTMLResponse)
|
||||
async def payments_index(request: Request, user: User = Depends(check_admin)):
|
||||
return template_renderer().TemplateResponse(
|
||||
"payments/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.json(),
|
||||
"ajax": _is_ajax_request(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -411,23 +426,22 @@ async def hex_to_uuid4(hex_value: str):
|
||||
|
||||
|
||||
@generic_router.get("/lnurlwallet", response_class=RedirectResponse)
|
||||
async def lnurlwallet(request: Request):
|
||||
async def lnurlwallet(request: Request, lightning: str = ""):
|
||||
"""
|
||||
If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with
|
||||
their smartphone camera, or a QR scanner app, they can follow the link provided to
|
||||
claim their satoshis and get an instant LNbits wallet! lnbits/withdraw docs
|
||||
"""
|
||||
|
||||
lightning_param = request.query_params.get("lightning")
|
||||
if not lightning_param:
|
||||
if not lightning:
|
||||
return {"status": "ERROR", "reason": "lightning parameter not provided."}
|
||||
if not settings.lnbits_allow_new_accounts:
|
||||
return {"status": "ERROR", "reason": "New accounts are not allowed."}
|
||||
|
||||
lnurl = lnurl_decode(lightning_param)
|
||||
lnurl = lnurl_decode(lightning)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
||||
check_callback_url(lnurl)
|
||||
res1 = await client.get(lnurl, timeout=2)
|
||||
res1.raise_for_status()
|
||||
data1 = res1.json()
|
||||
@@ -442,19 +456,23 @@ async def lnurlwallet(request: Request):
|
||||
detail="Invalid lnurl. Expected maxWithdrawable",
|
||||
)
|
||||
account = await create_user_account()
|
||||
wallet = await create_wallet(user_id=account.id)
|
||||
_, payment_request = await create_invoice(
|
||||
wallet = account.wallets[0]
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
amount=data1.get("maxWithdrawable") / 1000,
|
||||
memo=data1.get("defaultDescription", "lnurl wallet withdraw"),
|
||||
)
|
||||
url = data1.get("callback")
|
||||
params = {"k1": data1.get("k1"), "pr": payment_request}
|
||||
params = {"k1": data1.get("k1"), "pr": payment.bolt11}
|
||||
callback = url + ("&" if urlparse(url).query else "?") + urlencode(params)
|
||||
|
||||
res2 = await client.get(callback, timeout=2)
|
||||
res2 = await client.get(callback, timeout=5)
|
||||
res2.raise_for_status()
|
||||
|
||||
return RedirectResponse(
|
||||
f"/wallet?usr={account.id}&wal={wallet.id}",
|
||||
)
|
||||
|
||||
|
||||
def _is_ajax_request(request: Request):
|
||||
return request.headers.get("X-Requested-With", None) == "XMLHttpRequest"
|
||||
|
||||
@@ -69,7 +69,7 @@ public_node_router = APIRouter(
|
||||
@node_router.get(
|
||||
"/ok",
|
||||
description="Check if node api can be enabled",
|
||||
status_code=200,
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(require_node)],
|
||||
)
|
||||
async def api_get_ok():
|
||||
@@ -95,6 +95,14 @@ async def api_get_channels(
|
||||
return await node.get_channels()
|
||||
|
||||
|
||||
@node_router.get("/channels/{channel_id}")
|
||||
async def api_get_channel(
|
||||
channel_id: str,
|
||||
node: Node = Depends(require_node),
|
||||
) -> Optional[NodeChannel]:
|
||||
return await node.get_channel(channel_id)
|
||||
|
||||
|
||||
@super_node_router.post("/channels", response_model=ChannelPoint)
|
||||
async def api_create_channel(
|
||||
node: Node = Depends(require_node),
|
||||
@@ -125,7 +133,17 @@ async def api_delete_channel(
|
||||
)
|
||||
|
||||
|
||||
@node_router.get("/payments", response_model=Page[NodePayment])
|
||||
@super_node_router.put("/channels/{channel_id}")
|
||||
async def api_set_channel_fees(
|
||||
channel_id: str,
|
||||
node: Node = Depends(require_node),
|
||||
fee_ppm: int = Body(None),
|
||||
fee_base_msat: int = Body(None),
|
||||
):
|
||||
await node.set_channel_fee(channel_id, fee_base_msat, fee_ppm)
|
||||
|
||||
|
||||
@node_router.get("/payments")
|
||||
async def api_get_payments(
|
||||
node: Node = Depends(require_node),
|
||||
filters: Filters = Depends(parse_filters(NodePaymentsFilters)),
|
||||
@@ -138,7 +156,7 @@ async def api_get_payments(
|
||||
return await node.get_payments(filters)
|
||||
|
||||
|
||||
@node_router.get("/invoices", response_model=Page[NodeInvoice])
|
||||
@node_router.get("/invoices")
|
||||
async def api_get_invoices(
|
||||
node: Node = Depends(require_node),
|
||||
filters: Filters = Depends(parse_filters(NodeInvoiceFilters)),
|
||||
@@ -151,7 +169,7 @@ async def api_get_invoices(
|
||||
return await node.get_invoices(filters)
|
||||
|
||||
|
||||
@node_router.get("/peers", response_model=List[NodePeerInfo])
|
||||
@node_router.get("/peers")
|
||||
async def api_get_peers(node: Node = Depends(require_node)) -> List[NodePeerInfo]:
|
||||
return await node.get_peers()
|
||||
|
||||
@@ -197,5 +215,5 @@ async def api_get_1ml_stats(node: Node = Depends(require_node)) -> Optional[Node
|
||||
return r.json()["noderank"]
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Node not found on 1ml.com"
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Node not found on 1ml.com"
|
||||
) from exc
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
import ssl
|
||||
from http import HTTPStatus
|
||||
from math import ceil
|
||||
from typing import List, Optional
|
||||
@@ -13,13 +12,15 @@ from fastapi import (
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud.payments import (
|
||||
get_payment_count_stats,
|
||||
get_wallets_stats,
|
||||
)
|
||||
from lnbits.core.models import (
|
||||
CreateInvoice,
|
||||
CreateLnurl,
|
||||
@@ -27,18 +28,33 @@ from lnbits.core.models import (
|
||||
KeyType,
|
||||
PayLnurlWData,
|
||||
Payment,
|
||||
PaymentCountField,
|
||||
PaymentCountStat,
|
||||
PaymentDailyStats,
|
||||
PaymentFilters,
|
||||
PaymentHistoryPoint,
|
||||
PaymentWalletStats,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.core.services.payments import (
|
||||
get_payments_daily_stats,
|
||||
update_pending_payment,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
parse_filters,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import filter_dict_keys, generate_filter_params_openapi
|
||||
from lnbits.helpers import (
|
||||
check_callback_url,
|
||||
filter_dict_keys,
|
||||
generate_filter_params_openapi,
|
||||
)
|
||||
from lnbits.lnurl import decode as lnurl_decode
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
@@ -57,7 +73,6 @@ from ..services import (
|
||||
pay_invoice,
|
||||
update_pending_payments,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
|
||||
|
||||
@@ -98,6 +113,64 @@ async def api_payments_history(
|
||||
return await get_payments_history(key_info.wallet.id, group, filters)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/count",
|
||||
name="Get payments history for all users",
|
||||
dependencies=[Depends(check_admin)],
|
||||
response_model=List[PaymentCountStat],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_counting_stats(
|
||||
count_by: PaymentCountField = Query("tag"),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
return await get_payment_count_stats(count_by, filters)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/wallets",
|
||||
name="Get payments history for all users",
|
||||
dependencies=[Depends(check_admin)],
|
||||
response_model=List[PaymentWalletStats],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_wallets_stats(
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
return await get_wallets_stats(filters)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/stats/daily",
|
||||
name="Get payments history per day",
|
||||
response_model=List[PaymentDailyStats],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
)
|
||||
async def api_payments_daily_stats(
|
||||
user: User = Depends(check_user_exists),
|
||||
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
|
||||
if not user.admin:
|
||||
exc = HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="Missing wallet id.",
|
||||
)
|
||||
wallet_filter = next(
|
||||
(f for f in filters.filters if f.field == "wallet_id"), None
|
||||
)
|
||||
if not wallet_filter:
|
||||
raise exc
|
||||
wallet_id = list((wallet_filter.values or {}).values())
|
||||
if len(wallet_id) == 0:
|
||||
raise exc
|
||||
if not user.get_wallet(wallet_id[0]):
|
||||
raise exc
|
||||
return await get_payments_daily_stats(filters)
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/paginated",
|
||||
name="Payment List",
|
||||
@@ -110,13 +183,14 @@ async def api_payments_paginated(
|
||||
key_info: WalletTypeInfo = Depends(require_invoice_key),
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
await update_pending_payments(key_info.wallet.id)
|
||||
page = await get_payments_paginated(
|
||||
wallet_id=key_info.wallet.id,
|
||||
pending=True,
|
||||
complete=True,
|
||||
filters=filters,
|
||||
)
|
||||
for payment in page.data:
|
||||
if payment.pending:
|
||||
await update_pending_payment(payment)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
@@ -162,6 +236,7 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
try:
|
||||
check_callback_url(data.lnurl_callback)
|
||||
r = await client.get(
|
||||
data.lnurl_callback,
|
||||
params={"pr": payment.bolt11},
|
||||
@@ -182,6 +257,23 @@ async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
|
||||
return payment
|
||||
|
||||
|
||||
@payment_router.get(
|
||||
"/all/paginated",
|
||||
name="Payment List",
|
||||
summary="get paginated list of payments",
|
||||
response_description="list of payments",
|
||||
response_model=Page[Payment],
|
||||
openapi_extra=generate_filter_params_openapi(PaymentFilters),
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_all_payments_paginated(
|
||||
filters: Filters = Depends(parse_filters(PaymentFilters)),
|
||||
):
|
||||
return await get_payments_paginated(
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@payment_router.post(
|
||||
"",
|
||||
summary="Create or pay an invoice",
|
||||
@@ -257,6 +349,7 @@ async def api_payments_pay_lnurl(
|
||||
amount_msat = ceil(amount_msat // 1000) * 1000
|
||||
else:
|
||||
amount_msat = data.amount
|
||||
check_callback_url(data.callback)
|
||||
r = await client.get(
|
||||
data.callback,
|
||||
params={"amount": amount_msat, "comment": data.comment},
|
||||
@@ -265,7 +358,8 @@ async def api_payments_pay_lnurl(
|
||||
if r.is_error:
|
||||
raise httpx.ConnectError("LNURL callback connection error")
|
||||
r.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.RequestError) as exc:
|
||||
except (httpx.HTTPError, ssl.SSLError) as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Failed to connect to {domain}.",
|
||||
@@ -313,47 +407,6 @@ async def api_payments_pay_lnurl(
|
||||
return payment
|
||||
|
||||
|
||||
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||
"""
|
||||
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
|
||||
Listenes invoming payments for a wallet and yields jsons with payment details.
|
||||
"""
|
||||
this_wallet_id = wallet.id
|
||||
|
||||
payment_queue: asyncio.Queue[Payment] = asyncio.Queue(0)
|
||||
|
||||
uid = f"{this_wallet_id}_{str(uuid.uuid4())[:8]}"
|
||||
logger.debug(f"adding sse listener for wallet: {uid}")
|
||||
api_invoice_listeners[uid] = payment_queue
|
||||
|
||||
try:
|
||||
while settings.lnbits_running:
|
||||
if await request.is_disconnected():
|
||||
await request.close()
|
||||
break
|
||||
payment: Payment = await payment_queue.get()
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
logger.debug("sse listener: payment received", payment)
|
||||
yield {"data": payment.json(), "event": "payment-received"}
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in sse: {exc}")
|
||||
finally:
|
||||
api_invoice_listeners.pop(uid)
|
||||
|
||||
|
||||
@payment_router.get("/sse")
|
||||
async def api_payments_sse(
|
||||
request: Request, key_info: WalletTypeInfo = Depends(require_invoice_key)
|
||||
):
|
||||
return EventSourceResponse(
|
||||
subscribe_wallet_invoices(request, key_info.wallet),
|
||||
ping=20,
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
# TODO: refactor this route into a public and admin one
|
||||
@payment_router.get("/{payment_hash}")
|
||||
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
|
||||
@@ -414,7 +467,6 @@ async def api_payment_pay_with_nfc(
|
||||
payment_request: str,
|
||||
lnurl_data: PayLnurlWData,
|
||||
) -> JSONResponse:
|
||||
|
||||
lnurl = lnurl_data.lnurl_w.lower()
|
||||
|
||||
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
|
||||
@@ -423,6 +475,7 @@ async def api_payment_pay_with_nfc(
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
|
||||
try:
|
||||
check_callback_url(url)
|
||||
lnurl_req = await client.get(url, timeout=10)
|
||||
if lnurl_req.is_error:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
from ..crud import get_standalone_payment
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
public_router = APIRouter(tags=["Core"])
|
||||
|
||||
|
||||
@public_router.get("/public/v1/payment/{payment_hash}")
|
||||
async def api_public_payment_longpolling(payment_hash):
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
# TODO: refactor to use PaymentState
|
||||
if payment.success:
|
||||
return {"status": "paid"}
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment.bolt11)
|
||||
if invoice.has_expired():
|
||||
return {"status": "expired"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
|
||||
) from exc
|
||||
|
||||
payment_queue = asyncio.Queue(0)
|
||||
|
||||
logger.debug(f"adding standalone invoice listener for hash: {payment_hash}")
|
||||
api_invoice_listeners[payment_hash] = payment_queue
|
||||
|
||||
response = None
|
||||
|
||||
async def payment_info_receiver():
|
||||
for payment in await payment_queue.get():
|
||||
if payment.payment_hash == payment_hash:
|
||||
nonlocal response
|
||||
response = {"status": "paid"}
|
||||
|
||||
async def timeouter(cancel_scope):
|
||||
await asyncio.sleep(45)
|
||||
cancel_scope.cancel()
|
||||
|
||||
cancel_scope = asyncio.create_task(payment_info_receiver())
|
||||
asyncio.create_task(timeouter(cancel_scope)) # noqa: RUF006
|
||||
|
||||
if response:
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout")
|
||||
@@ -24,16 +24,18 @@ from lnbits.core.crud import (
|
||||
from lnbits.core.models import (
|
||||
AccountFilters,
|
||||
AccountOverview,
|
||||
CreateTopup,
|
||||
CreateUser,
|
||||
SimpleStatus,
|
||||
UpdateBalance,
|
||||
User,
|
||||
UserExtra,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.services import (
|
||||
create_user_account_no_ckeck,
|
||||
enqueue_notification,
|
||||
update_user_account,
|
||||
update_user_extensions,
|
||||
update_wallet_balance,
|
||||
@@ -267,16 +269,24 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> SimpleStatu
|
||||
|
||||
|
||||
@users_router.put(
|
||||
"/topup",
|
||||
name="Topup",
|
||||
"/balance",
|
||||
name="UpdateBalance",
|
||||
summary="Update balance for a particular wallet.",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(data: CreateTopup) -> SimpleStatus:
|
||||
await get_wallet(data.id)
|
||||
if settings.lnbits_backend_wallet_class == "VoidWallet":
|
||||
raise Exception("VoidWallet active")
|
||||
async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
|
||||
wallet = await get_wallet(data.id)
|
||||
if not wallet:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
|
||||
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
|
||||
enqueue_notification(
|
||||
NotificationType.balance_update,
|
||||
{
|
||||
"amount": int(data.amount),
|
||||
"wallet_id": wallet.id,
|
||||
"wallet_name": wallet.name,
|
||||
"balance": wallet.balance,
|
||||
},
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
|
||||
return SimpleStatus(success=True, message="Balance updated.")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
@@ -8,13 +9,10 @@ from fastapi import (
|
||||
HTTPException,
|
||||
)
|
||||
|
||||
from lnbits.core.models import (
|
||||
CreateWallet,
|
||||
KeyType,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_user_exists,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
@@ -56,9 +54,25 @@ async def api_update_wallet_name(
|
||||
}
|
||||
|
||||
|
||||
@wallet_router.put("/reset/{wallet_id}")
|
||||
async def api_reset_wallet_keys(
|
||||
wallet_id: str, user: User = Depends(check_user_exists)
|
||||
) -> Wallet:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
wallet.adminkey = uuid4().hex
|
||||
wallet.inkey = uuid4().hex
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
@wallet_router.patch("")
|
||||
async def api_update_wallet(
|
||||
name: Optional[str] = Body(None),
|
||||
icon: Optional[str] = Body(None),
|
||||
color: Optional[str] = Body(None),
|
||||
currency: Optional[str] = Body(None),
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
@@ -66,18 +80,24 @@ async def api_update_wallet(
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
wallet.name = name or wallet.name
|
||||
wallet.extra.icon = icon or wallet.extra.icon
|
||||
wallet.extra.color = color or wallet.extra.color
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
@wallet_router.delete("")
|
||||
@wallet_router.delete("/{wallet_id}")
|
||||
async def api_delete_wallet(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
wallet_id: str, user: User = Depends(check_user_exists)
|
||||
) -> None:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user.id:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
|
||||
await delete_wallet(
|
||||
user_id=wallet.wallet.user,
|
||||
wallet_id=wallet.wallet.id,
|
||||
user_id=wallet.user,
|
||||
wallet_id=wallet.id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ from lnbits.core.models import (
|
||||
CreateWebPushSubscription,
|
||||
WebPushSubscription,
|
||||
)
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
require_admin_key,
|
||||
check_user_exists,
|
||||
)
|
||||
|
||||
from ..crud import (
|
||||
@@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
|
||||
async def api_create_webpush_subscription(
|
||||
request: Request,
|
||||
data: CreateWebPushSubscription,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> WebPushSubscription:
|
||||
try:
|
||||
subscription = json.loads(data.subscription)
|
||||
endpoint = subscription["endpoint"]
|
||||
host = urlparse(str(request.url)).netloc
|
||||
|
||||
subscription = await get_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
subscription = await get_webpush_subscription(endpoint, user.id)
|
||||
if subscription:
|
||||
return subscription
|
||||
else:
|
||||
return await create_webpush_subscription(
|
||||
endpoint,
|
||||
wallet.wallet.user,
|
||||
user.id,
|
||||
data.subscription,
|
||||
host,
|
||||
)
|
||||
@@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
|
||||
@webpush_router.delete("", status_code=HTTPStatus.OK)
|
||||
async def api_delete_webpush_subscription(
|
||||
request: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
try:
|
||||
endpoint = unquote(
|
||||
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
|
||||
)
|
||||
count = await delete_webpush_subscription(endpoint, wallet.wallet.user)
|
||||
count = await delete_webpush_subscription(endpoint, user.id)
|
||||
return {"count": count}
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
|
||||
+59
-41
@@ -8,7 +8,7 @@ import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin
|
||||
from typing import Any, Generic, Literal, TypeVar, get_origin
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, ValidationError, root_validator
|
||||
@@ -22,6 +22,13 @@ POSTGRES = "POSTGRES"
|
||||
COCKROACH = "COCKROACH"
|
||||
SQLITE = "SQLITE"
|
||||
|
||||
DateTrunc = Literal["hour", "day", "month"]
|
||||
sqlite_formats = {
|
||||
"hour": "%Y-%m-%d %H:00:00",
|
||||
"day": "%Y-%m-%d 00:00:00",
|
||||
"month": "%Y-%m-01 00:00:00",
|
||||
}
|
||||
|
||||
if settings.lnbits_database_url:
|
||||
database_uri = settings.lnbits_database_url
|
||||
if database_uri.startswith("cockroachdb://"):
|
||||
@@ -58,8 +65,8 @@ def get_placeholder(model: Any, field: str) -> str:
|
||||
|
||||
|
||||
class Compat:
|
||||
type: Optional[str] = "<inherited>"
|
||||
schema: Optional[str] = "<inherited>"
|
||||
type: str | None = "<inherited>"
|
||||
schema: str | None = "<inherited>"
|
||||
|
||||
def interval_seconds(self, seconds: int) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
@@ -75,6 +82,18 @@ class Compat:
|
||||
return time.mktime(date.timetuple())
|
||||
return "<nothing>"
|
||||
|
||||
def datetime_grouping(self, group: DateTrunc):
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
return f"date_trunc('{group}', time)"
|
||||
elif self.type == SQLITE:
|
||||
return (
|
||||
"CAST (strftime('%s',datetime(strftime("
|
||||
f"'{sqlite_formats[group]}'"
|
||||
", time, 'unixepoch'))) AS INT)"
|
||||
)
|
||||
|
||||
return "<bad grouping>"
|
||||
|
||||
@property
|
||||
def timestamp_now(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
@@ -148,8 +167,8 @@ class Connection(Compat):
|
||||
async def fetchall(
|
||||
self,
|
||||
query: str,
|
||||
values: Optional[dict] = None,
|
||||
model: Optional[type[TModel]] = None,
|
||||
values: dict | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
) -> list[TModel]:
|
||||
params = self.rewrite_values(values) if values else {}
|
||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||
@@ -164,8 +183,8 @@ class Connection(Compat):
|
||||
async def fetchone(
|
||||
self,
|
||||
query: str,
|
||||
values: Optional[dict] = None,
|
||||
model: Optional[type[TModel]] = None,
|
||||
values: dict | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
) -> TModel:
|
||||
params = self.rewrite_values(values) if values else {}
|
||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||
@@ -192,11 +211,11 @@ class Connection(Compat):
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[list[str]] = None,
|
||||
values: Optional[dict] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[type[TModel]] = None,
|
||||
group_by: Optional[list[str]] = None,
|
||||
where: list[str] | None = None,
|
||||
values: dict | None = None,
|
||||
filters: Filters | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
group_by: list[str] | None = None,
|
||||
) -> Page[TModel]:
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
@@ -249,7 +268,7 @@ class Connection(Compat):
|
||||
total=count,
|
||||
)
|
||||
|
||||
async def execute(self, query: str, values: Optional[dict] = None):
|
||||
async def execute(self, query: str, values: dict | None = None):
|
||||
params = self.rewrite_values(values) if values else {}
|
||||
result = await self.conn.execute(text(self.rewrite_query(query)), params)
|
||||
await self.conn.commit()
|
||||
@@ -331,8 +350,8 @@ class Database(Compat):
|
||||
async def fetchall(
|
||||
self,
|
||||
query: str,
|
||||
values: Optional[dict] = None,
|
||||
model: Optional[type[TModel]] = None,
|
||||
values: dict | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
) -> list[TModel]:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetchall(query, values, model)
|
||||
@@ -340,8 +359,8 @@ class Database(Compat):
|
||||
async def fetchone(
|
||||
self,
|
||||
query: str,
|
||||
values: Optional[dict] = None,
|
||||
model: Optional[type[TModel]] = None,
|
||||
values: dict | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
) -> TModel:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetchone(query, values, model)
|
||||
@@ -359,16 +378,16 @@ class Database(Compat):
|
||||
async def fetch_page(
|
||||
self,
|
||||
query: str,
|
||||
where: Optional[list[str]] = None,
|
||||
values: Optional[dict] = None,
|
||||
filters: Optional[Filters] = None,
|
||||
model: Optional[type[TModel]] = None,
|
||||
group_by: Optional[list[str]] = None,
|
||||
where: list[str] | None = None,
|
||||
values: dict | None = None,
|
||||
filters: Filters | None = None,
|
||||
model: type[TModel] | None = None,
|
||||
group_by: list[str] | None = None,
|
||||
) -> Page[TModel]:
|
||||
async with self.connect() as conn:
|
||||
return await conn.fetch_page(query, where, values, filters, model, group_by)
|
||||
|
||||
async def execute(self, query: str, values: Optional[dict] = None):
|
||||
async def execute(self, query: str, values: dict | None = None):
|
||||
async with self.connect() as conn:
|
||||
return await conn.execute(query, values)
|
||||
|
||||
@@ -426,7 +445,7 @@ class Operator(Enum):
|
||||
|
||||
class FilterModel(BaseModel):
|
||||
__search_fields__: list[str] = []
|
||||
__sort_fields__: Optional[list[str]] = None
|
||||
__sort_fields__: list[str] | None = None
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -442,8 +461,8 @@ class Page(BaseModel, Generic[T]):
|
||||
class Filter(BaseModel, Generic[TFilterModel]):
|
||||
field: str
|
||||
op: Operator = Operator.EQ
|
||||
model: Optional[type[TFilterModel]]
|
||||
values: Optional[dict] = None
|
||||
model: type[TFilterModel] | None
|
||||
values: dict | None = None
|
||||
|
||||
@classmethod
|
||||
def parse_query(
|
||||
@@ -498,15 +517,15 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
"""
|
||||
|
||||
filters: list[Filter[TFilterModel]] = []
|
||||
search: Optional[str] = None
|
||||
search: str | None = None
|
||||
|
||||
offset: Optional[int] = None
|
||||
limit: Optional[int] = None
|
||||
offset: int | None = None
|
||||
limit: int | None = None
|
||||
|
||||
sortby: Optional[str] = None
|
||||
direction: Optional[Literal["asc", "desc"]] = None
|
||||
sortby: str | None = None
|
||||
direction: Literal["asc", "desc"] | None = None
|
||||
|
||||
model: Optional[type[TFilterModel]] = None
|
||||
model: type[TFilterModel] | None = None
|
||||
|
||||
@root_validator(pre=True)
|
||||
def validate_sortby(cls, values):
|
||||
@@ -528,18 +547,17 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
stmt += f"OFFSET {self.offset}"
|
||||
return stmt
|
||||
|
||||
def where(self, where_stmts: Optional[list[str]] = None) -> str:
|
||||
def where(self, where_stmts: list[str] | None = None) -> str:
|
||||
if not where_stmts:
|
||||
where_stmts = []
|
||||
if self.filters:
|
||||
for page_filter in self.filters:
|
||||
where_stmts.append(page_filter.statement)
|
||||
if self.search and self.model:
|
||||
fields = self.model.__search_fields__
|
||||
if DB_TYPE == POSTGRES:
|
||||
where_stmts.append(f"lower(concat({', '.join(fields)})) LIKE :search")
|
||||
elif DB_TYPE == SQLITE:
|
||||
where_stmts.append(f"lower({'||'.join(fields)}) LIKE :search")
|
||||
if self.search and self.model and self.model.__search_fields__:
|
||||
where_stmts.append(
|
||||
f"lower(concat({', '.join(self.model.__search_fields__)})) LIKE :search"
|
||||
)
|
||||
|
||||
if where_stmts:
|
||||
return "WHERE " + " AND ".join(where_stmts)
|
||||
return ""
|
||||
@@ -549,7 +567,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
||||
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
|
||||
return ""
|
||||
|
||||
def values(self, values: Optional[dict] = None) -> dict:
|
||||
def values(self, values: dict | None = None) -> dict:
|
||||
if not values:
|
||||
values = {}
|
||||
if self.filters:
|
||||
@@ -623,7 +641,7 @@ def model_to_dict(model: BaseModel) -> dict:
|
||||
return _dict
|
||||
|
||||
|
||||
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
|
||||
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
|
||||
"""convert a dictionary or JSON string to a Pydantic model"""
|
||||
if isinstance(value, str):
|
||||
if value == "null":
|
||||
|
||||
+65
-23
@@ -18,6 +18,7 @@ from lnbits.core.crud import (
|
||||
get_user_from_account,
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user_access_control_lists
|
||||
from lnbits.core.models import (
|
||||
AccessTokenPayload,
|
||||
Account,
|
||||
@@ -27,6 +28,7 @@ from lnbits.core.models import (
|
||||
WalletTypeInfo,
|
||||
)
|
||||
from lnbits.db import Connection, Filter, Filters, TFilterModel
|
||||
from lnbits.helpers import path_segments
|
||||
from lnbits.settings import AuthMethods, settings
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth", auto_error=False)
|
||||
@@ -140,9 +142,13 @@ async def check_user_exists(
|
||||
usr: Optional[UUID4] = None,
|
||||
) -> User:
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token)
|
||||
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
account = await get_account(usr.hex)
|
||||
if account and account.is_admin:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
|
||||
)
|
||||
else:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
|
||||
|
||||
@@ -161,13 +167,14 @@ async def check_user_exists(
|
||||
|
||||
|
||||
async def optional_user_id(
|
||||
r: Request,
|
||||
access_token: Annotated[Optional[str], Depends(check_access_token)],
|
||||
usr: Optional[UUID4] = None,
|
||||
) -> Optional[str]:
|
||||
if usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
|
||||
return usr.hex
|
||||
if access_token:
|
||||
account = await _get_account_from_token(access_token)
|
||||
account = await _get_account_from_token(access_token, r["path"], r["method"])
|
||||
return account.id if account else None
|
||||
|
||||
return None
|
||||
@@ -188,6 +195,10 @@ async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No admin privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "Admin users must have credentials configured."
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -197,6 +208,10 @@ async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) ->
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "User not authorized. No super user privileges."
|
||||
)
|
||||
if not user.has_password:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN, "Super user must have credentials configured."
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@@ -247,19 +262,18 @@ async def check_user_extension_access(
|
||||
success=False, message=f"User not authorized for extension '{ext_id}'."
|
||||
)
|
||||
|
||||
if settings.is_extension_id(ext_id):
|
||||
if settings.is_installed_extension_id(ext_id):
|
||||
ext_ids = await get_user_active_extensions_ids(user_id, conn=conn)
|
||||
if ext_id not in ext_ids:
|
||||
return SimpleStatus(
|
||||
success=False, message=f"User extension '{ext_id}' not enabled."
|
||||
success=False, message=f"Extension '{ext_id}' not enabled."
|
||||
)
|
||||
|
||||
return SimpleStatus(success=True, message="OK")
|
||||
|
||||
|
||||
async def _check_user_extension_access(user_id: str, current_path: str):
|
||||
path = current_path.split("/")
|
||||
ext_id = path[3] if path[1] == "upgrades" else path[1]
|
||||
async def _check_user_extension_access(user_id: str, path: str):
|
||||
ext_id = path_segments(path)[0]
|
||||
status = await check_user_extension_access(user_id, ext_id)
|
||||
if not status.success:
|
||||
raise HTTPException(
|
||||
@@ -268,16 +282,14 @@ async def _check_user_extension_access(user_id: str, current_path: str):
|
||||
)
|
||||
|
||||
|
||||
async def _get_account_from_token(access_token) -> Optional[Account]:
|
||||
async def _get_account_from_token(
|
||||
access_token: str, path: str, method: str
|
||||
) -> Optional[Account]:
|
||||
try:
|
||||
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
|
||||
user = await _get_user_from_jwt_payload(payload)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
HTTPStatus.UNAUTHORIZED, "Data missing for access token."
|
||||
)
|
||||
|
||||
return user
|
||||
return await _get_account_from_jwt_payload(
|
||||
AccessTokenPayload(**payload), path, method
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(
|
||||
@@ -288,11 +300,41 @@ async def _get_account_from_token(access_token) -> Optional[Account]:
|
||||
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
|
||||
|
||||
|
||||
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
|
||||
if "sub" in payload and payload.get("sub"):
|
||||
return await get_account_by_username(str(payload.get("sub")))
|
||||
if "usr" in payload and payload.get("usr"):
|
||||
return await get_account(str(payload.get("usr")))
|
||||
if "email" in payload and payload.get("email"):
|
||||
return await get_account_by_email(str(payload.get("email")))
|
||||
return None
|
||||
async def _get_account_from_jwt_payload(
|
||||
payload: AccessTokenPayload, path: str, method: str
|
||||
) -> Optional[Account]:
|
||||
account = None
|
||||
if payload.sub:
|
||||
account = await get_account_by_username(payload.sub)
|
||||
elif payload.usr:
|
||||
account = await get_account(payload.usr)
|
||||
elif payload.email:
|
||||
account = await get_account_by_email(payload.email)
|
||||
|
||||
if not account:
|
||||
return None
|
||||
|
||||
if payload.api_token_id:
|
||||
await _check_account_api_access(account.id, payload.api_token_id, path, method)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
async def _check_account_api_access(
|
||||
user_id: str, token_id: str, path: str, method: str
|
||||
):
|
||||
segments = path.split("/")
|
||||
if len(segments) < 3:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
|
||||
|
||||
acls = await get_user_access_control_lists(user_id)
|
||||
acl = acls.get_acl_by_token_id(token_id)
|
||||
if not acl:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
|
||||
|
||||
path = "/" + "/".join(path_segments(path)[:3])
|
||||
endpoint = acl.get_endpoint(path)
|
||||
if not endpoint:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Path not allowed.")
|
||||
if not endpoint.supports_method(method):
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Method not allowed.")
|
||||
|
||||
+54
-3
@@ -8,7 +8,9 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from loguru import logger
|
||||
|
||||
from .helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .helpers import path_segments, template_renderer
|
||||
|
||||
|
||||
class PaymentError(Exception):
|
||||
@@ -26,9 +28,11 @@ class InvoiceError(Exception):
|
||||
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if not request.headers:
|
||||
return None
|
||||
if "text/html" not in request.headers.get("accept", ""):
|
||||
|
||||
if not _is_browser_request(request):
|
||||
return None
|
||||
|
||||
if (
|
||||
@@ -49,7 +53,14 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
|
||||
)
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"err": f"Error: {exc!s}",
|
||||
"status_code": int(status_code),
|
||||
"message": str(exc).split(":")[-1].strip(),
|
||||
},
|
||||
status_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,3 +130,43 @@ def register_exception_handlers(app: FastAPI):
|
||||
status_code=520,
|
||||
content={"detail": exc.message, "status": exc.status},
|
||||
)
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def error_handler_404(request: Request, exc: HTTPException):
|
||||
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
|
||||
|
||||
if not _is_browser_request(request):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
path = path_segments(request.url.path)[0]
|
||||
status_code = HTTPStatus.NOT_FOUND
|
||||
message: str = "Page not found."
|
||||
|
||||
if path in settings.lnbits_all_extensions_ids:
|
||||
status_code = HTTPStatus.FORBIDDEN
|
||||
message = f"Extension '{path}' not installed. Ask the admin to install it."
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"status_code": int(status_code), "message": message},
|
||||
int(status_code),
|
||||
)
|
||||
|
||||
|
||||
def _is_browser_request(request: Request) -> bool:
|
||||
# Check a few common browser agents, also not fail proof
|
||||
if "api/v1" in request.url.path:
|
||||
return False
|
||||
|
||||
browser_agents = ["Mozilla", "Chrome", "Safari"]
|
||||
if any(agent in request.headers.get("user-agent", "") for agent in browser_agents):
|
||||
return True
|
||||
|
||||
if "text/html" in request.headers.get("accept", ""):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
+106
-13
@@ -5,10 +5,13 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Type
|
||||
from urllib import request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import shortuuid
|
||||
from fastapi import Request
|
||||
from fastapi.routing import APIRoute
|
||||
from packaging import version
|
||||
from pydantic.schema import field_schema
|
||||
|
||||
@@ -75,6 +78,7 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
t.env.globals["LNBITS_SHOW_HOME_PAGE_ELEMENTS"] = (
|
||||
settings.lnbits_show_home_page_elements
|
||||
)
|
||||
t.env.globals["LNBITS_CUSTOM_IMAGE"] = settings.lnbits_custom_image
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE"] = settings.lnbits_custom_badge
|
||||
t.env.globals["LNBITS_CUSTOM_BADGE_COLOR"] = settings.lnbits_custom_badge_color
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
@@ -95,11 +99,26 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
|
||||
settings.lnbits_node_ui and get_node_class() is not None
|
||||
)
|
||||
t.env.globals["LNBITS_NODE_UI_AVAILABLE"] = get_node_class() is not None
|
||||
t.env.globals["EXTENSIONS"] = list(settings.lnbits_all_extensions_ids)
|
||||
t.env.globals["EXTENSIONS"] = list(settings.lnbits_installed_extensions_ids)
|
||||
|
||||
if settings.lnbits_custom_logo:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
if settings.lnbits_default_reaction:
|
||||
t.env.globals["USE_DEFAULT_REACTION"] = settings.lnbits_default_reaction
|
||||
|
||||
if settings.lnbits_default_theme:
|
||||
t.env.globals["USE_DEFAULT_THEME"] = settings.lnbits_default_theme
|
||||
|
||||
if settings.lnbits_default_border:
|
||||
t.env.globals["USE_DEFAULT_BORDER"] = settings.lnbits_default_border
|
||||
|
||||
if settings.lnbits_default_gradient:
|
||||
t.env.globals["USE_DEFAULT_GRADIENT"] = settings.lnbits_default_gradient
|
||||
|
||||
if settings.lnbits_default_bgimage:
|
||||
t.env.globals["USE_DEFAULT_BGIMAGE"] = settings.lnbits_default_bgimage
|
||||
|
||||
if settings.bundle_assets:
|
||||
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
|
||||
t.env.globals["INCLUDED_CSS"] = ["bundle.min.css"]
|
||||
@@ -198,27 +217,42 @@ def is_valid_pubkey(pubkey: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.auth_token_expire_minutes
|
||||
)
|
||||
to_encode = data.copy()
|
||||
to_encode.update({"exp": expire})
|
||||
def create_access_token(data: dict, token_expire_minutes: Optional[int] = None) -> str:
|
||||
minutes = token_expire_minutes or settings.auth_token_expire_minutes
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
to_encode = {k: v for k, v in data.items() if v is not None}
|
||||
to_encode.update({"exp": expire}) # todo:check expiration
|
||||
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
|
||||
|
||||
|
||||
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
"""Encrypt message with the internal secret key"""
|
||||
def encrypt_internal_message(
|
||||
m: Optional[str] = None, urlsafe: bool = False
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Encrypt message with the internal secret key
|
||||
|
||||
Args:
|
||||
m: Message to encrypt
|
||||
urlsafe: Whether to use URL-safe base64 encoding
|
||||
"""
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode())
|
||||
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode(), urlsafe=urlsafe)
|
||||
|
||||
|
||||
def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
|
||||
"""Decrypt message with the internal secret key"""
|
||||
def decrypt_internal_message(
|
||||
m: Optional[str] = None, urlsafe: bool = False
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Decrypt message with the internal secret key
|
||||
|
||||
Args:
|
||||
m: Message to decrypt
|
||||
urlsafe: Whether the message uses URL-safe base64 encoding
|
||||
"""
|
||||
if not m:
|
||||
return None
|
||||
return AESCipher(key=settings.auth_secret_key).decrypt(m)
|
||||
return AESCipher(key=settings.auth_secret_key).decrypt(m, urlsafe=urlsafe)
|
||||
|
||||
|
||||
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
|
||||
@@ -241,6 +275,30 @@ def version_parse(v: str):
|
||||
return version.parse("0.0.0")
|
||||
|
||||
|
||||
def is_lnbits_version_ok(
|
||||
min_lnbits_version: Optional[str], max_lnbits_version: Optional[str]
|
||||
) -> bool:
|
||||
if min_lnbits_version and (
|
||||
version_parse(min_lnbits_version) > version_parse(settings.version)
|
||||
):
|
||||
return False
|
||||
if max_lnbits_version and (
|
||||
version_parse(max_lnbits_version) <= version_parse(settings.version)
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_callback_url(url: str):
|
||||
netloc = urlparse(url).netloc
|
||||
for rule in settings.lnbits_callback_url_rules:
|
||||
if re.match(rule, netloc) is None:
|
||||
raise ValueError(
|
||||
f"Callback not allowed. URL: {url}. Netloc: {netloc}. Rule: {rule}"
|
||||
)
|
||||
|
||||
|
||||
def download_url(url, save_path):
|
||||
with request.urlopen(url, timeout=60) as dl_file:
|
||||
with open(save_path, "wb") as out_file:
|
||||
@@ -255,3 +313,38 @@ def file_hash(filename):
|
||||
while n := f.readinto(mv):
|
||||
h.update(mv[:n])
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def get_api_routes(routes: list) -> dict[str, str]:
|
||||
data = {}
|
||||
for route in routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
segments = route.path.split("/")
|
||||
if len(segments) < 3:
|
||||
continue
|
||||
if "/".join(segments[1:3]) == "api/v1":
|
||||
data["/".join(segments[0:4])] = segments[3].capitalize()
|
||||
elif "/".join(segments[2:4]) == "api/v1":
|
||||
data["/".join(segments[0:4])] = segments[1].capitalize()
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def path_segments(path: str) -> list[str]:
|
||||
path = path.strip("/")
|
||||
segments = path.split("/")
|
||||
if len(segments) < 2:
|
||||
return segments
|
||||
if segments[0] == "upgrades":
|
||||
return segments[2:]
|
||||
return segments[0:]
|
||||
|
||||
|
||||
def normalize_path(path: Optional[str]) -> str:
|
||||
path = path or ""
|
||||
return "/" + "/".join(path_segments(path))
|
||||
|
||||
|
||||
def normalized_path(request: Request) -> str:
|
||||
return "/" + "/".join(path_segments(request.url.path))
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import HTTPException, Request, Response
|
||||
@@ -31,21 +32,21 @@ class LnurlErrorResponseHandler(APIRoute):
|
||||
except (InvoiceError, PaymentError) as exc:
|
||||
logger.debug(f"Wallet Error: {exc}")
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
status_code=HTTPStatus.OK,
|
||||
content={"status": "ERROR", "reason": f"{exc.message}"},
|
||||
)
|
||||
return response
|
||||
except HTTPException as exc:
|
||||
logger.debug(f"HTTPException: {exc}")
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
status_code=HTTPStatus.OK,
|
||||
content={"status": "ERROR", "reason": f"{exc.detail}"},
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
logger.error("Unknown Error:", exc)
|
||||
response = JSONResponse(
|
||||
status_code=200,
|
||||
status_code=HTTPStatus.OK,
|
||||
content={
|
||||
"status": "ERROR",
|
||||
"reason": f"UNKNOWN ERROR: {exc!s}",
|
||||
|
||||
+12
-20
@@ -11,12 +11,11 @@ from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.models import AuditEntry
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.helpers import normalize_path, template_renderer
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@@ -83,7 +82,11 @@ class InstalledExtensionMiddleware:
|
||||
return HTMLResponse(
|
||||
status_code=status_code,
|
||||
content=template_renderer()
|
||||
.TemplateResponse(Request(scope), "error.html", {"err": msg})
|
||||
.TemplateResponse(
|
||||
Request(scope),
|
||||
"error.html",
|
||||
{"err": msg, "status_code": status_code, "message": msg},
|
||||
)
|
||||
.body,
|
||||
)
|
||||
|
||||
@@ -93,18 +96,6 @@ class InstalledExtensionMiddleware:
|
||||
)
|
||||
|
||||
|
||||
class CustomGZipMiddleware(GZipMiddleware):
|
||||
def __init__(self, *args, exclude_paths=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.exclude_paths = exclude_paths or []
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if "path" in scope and scope["path"] in self.exclude_paths:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
class ExtensionsRedirectMiddleware:
|
||||
# Extensions are allowed to specify redirect paths. A call to a path outside the
|
||||
# scope of the extension can be redirected to one of the extension's endpoints.
|
||||
@@ -127,7 +118,6 @@ class ExtensionsRedirectMiddleware:
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
def __init__(self, app: ASGIApp, audit_queue: asyncio.Queue) -> None:
|
||||
super().__init__(app)
|
||||
self.audit_queue = audit_queue
|
||||
@@ -144,8 +134,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
assert response
|
||||
return response
|
||||
finally:
|
||||
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
await self._log_audit(request, response, duration, request_details)
|
||||
if request_details:
|
||||
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
await self._log_audit(request, response, duration, request_details)
|
||||
|
||||
async def _log_audit(
|
||||
self,
|
||||
@@ -169,6 +160,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
if settings.is_super_user(user_id):
|
||||
user_id = "super_user"
|
||||
component = "core"
|
||||
path = normalize_path(path)
|
||||
if path and not path.startswith("/api"):
|
||||
component = path.split("/")[1]
|
||||
|
||||
@@ -230,7 +222,7 @@ def add_ip_block_middleware(app: FastAPI):
|
||||
async def block_allow_ip_middleware(request: Request, call_next):
|
||||
if not request.client:
|
||||
return JSONResponse(
|
||||
status_code=403, # Forbidden
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
content={"detail": "No request client"},
|
||||
)
|
||||
if (
|
||||
@@ -238,7 +230,7 @@ def add_ip_block_middleware(app: FastAPI):
|
||||
and request.client.host not in settings.lnbits_allowed_ips
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=403, # Forbidden
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
content={"detail": "IP is blocked"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
+54
-40
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -15,10 +15,10 @@ if TYPE_CHECKING:
|
||||
|
||||
class NodePeerInfo(BaseModel):
|
||||
id: str
|
||||
alias: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
last_timestamp: Optional[int] = None
|
||||
addresses: Optional[list[str]] = None
|
||||
alias: str | None = None
|
||||
color: str | None = None
|
||||
last_timestamp: int | None = None
|
||||
addresses: list[str] | None = None
|
||||
|
||||
|
||||
class ChannelState(Enum):
|
||||
@@ -38,22 +38,29 @@ class ChannelPoint(BaseModel):
|
||||
funding_txid: str
|
||||
output_index: int
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.funding_txid}:{self.output_index}"
|
||||
|
||||
|
||||
class NodeChannel(BaseModel):
|
||||
short_id: Optional[str] = None
|
||||
point: Optional[ChannelPoint] = None
|
||||
peer_id: str
|
||||
balance: ChannelBalance
|
||||
state: ChannelState
|
||||
name: Optional[str]
|
||||
color: Optional[str]
|
||||
# could be optional for closing/pending channels on lndrest
|
||||
id: str | None = None
|
||||
short_id: str | None = None
|
||||
point: ChannelPoint | None = None
|
||||
name: str | None = None
|
||||
color: str | None = None
|
||||
fee_ppm: int | None = None
|
||||
fee_base_msat: int | None = None
|
||||
|
||||
|
||||
class ChannelStats(BaseModel):
|
||||
counts: dict[ChannelState, int]
|
||||
avg_size: int
|
||||
biggest_size: Optional[int]
|
||||
smallest_size: Optional[int]
|
||||
biggest_size: int | None
|
||||
smallest_size: int | None
|
||||
total_capacity: int
|
||||
|
||||
@classmethod
|
||||
@@ -88,9 +95,9 @@ class ChannelStats(BaseModel):
|
||||
|
||||
class NodeFees(BaseModel):
|
||||
total_msat: int
|
||||
daily_msat: Optional[int] = None
|
||||
weekly_msat: Optional[int] = None
|
||||
monthly_msat: Optional[int] = None
|
||||
daily_msat: int | None = None
|
||||
weekly_msat: int | None = None
|
||||
monthly_msat: int | None = None
|
||||
|
||||
|
||||
class PublicNodeInfo(BaseModel):
|
||||
@@ -114,25 +121,25 @@ class NodeInfoResponse(PublicNodeInfo):
|
||||
class NodePayment(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
fee: Optional[int] = None
|
||||
memo: Optional[str] = None
|
||||
fee: int | None = None
|
||||
memo: str | None = None
|
||||
time: int
|
||||
bolt11: Optional[str] = None
|
||||
preimage: Optional[str]
|
||||
bolt11: str | None = None
|
||||
preimage: str | None
|
||||
payment_hash: str
|
||||
expiry: Optional[float] = None
|
||||
destination: Optional[NodePeerInfo] = None
|
||||
expiry: float | None = None
|
||||
destination: NodePeerInfo | None = None
|
||||
|
||||
|
||||
class NodeInvoice(BaseModel):
|
||||
pending: bool
|
||||
amount: int
|
||||
memo: Optional[str]
|
||||
memo: str | None
|
||||
bolt11: str
|
||||
preimage: Optional[str]
|
||||
preimage: str | None
|
||||
payment_hash: str
|
||||
paid_at: Optional[int] = None
|
||||
expiry: Optional[int] = None
|
||||
paid_at: int | None = None
|
||||
expiry: int | None = None
|
||||
|
||||
|
||||
class NodeInvoiceFilters(FilterModel):
|
||||
@@ -144,11 +151,10 @@ class NodePaymentsFilters(FilterModel):
|
||||
|
||||
|
||||
class Node(ABC):
|
||||
wallet: Wallet
|
||||
|
||||
def __init__(self, wallet: Wallet):
|
||||
self.wallet = wallet
|
||||
self.id: Optional[str] = None
|
||||
self.id: str | None = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -161,7 +167,7 @@ class Node(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def _get_id(self) -> str:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_peers(self) -> list[NodePeerInfo]:
|
||||
peer_ids = await self.get_peer_ids()
|
||||
@@ -169,15 +175,15 @@ class Node(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def get_peer_ids(self) -> list[str]:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def connect_peer(self, uri: str):
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect_peer(self, peer_id: str):
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
|
||||
@@ -197,27 +203,35 @@ class Node(ABC):
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
push_amount: int | None = None,
|
||||
fee_rate: int | None = None,
|
||||
) -> ChannelPoint:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
short_id: str | None = None,
|
||||
point: ChannelPoint | None = None,
|
||||
force: bool = False,
|
||||
):
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_info(self) -> NodeInfoResponse:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_public_info(self) -> PublicNodeInfo:
|
||||
info = await self.get_info()
|
||||
@@ -227,10 +241,10 @@ class Node(ABC):
|
||||
async def get_payments(
|
||||
self, filters: Filters[NodePaymentsFilters]
|
||||
) -> Page[NodePayment]:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_invoices(
|
||||
self, filters: Filters[NodeInvoiceFilters]
|
||||
) -> Page[NodeInvoice]:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
|
||||
+66
-36
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -47,9 +47,13 @@ def catch_rpc_errors(f):
|
||||
except RpcError as exc:
|
||||
msg = exc.error["message"]
|
||||
if exc.error["code"] == -32602:
|
||||
raise HTTPException(status_code=400, detail=msg) from exc
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=msg
|
||||
) from exc
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=msg) from exc
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=msg
|
||||
) from exc
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -62,6 +66,28 @@ class CoreLightningNode(Node):
|
||||
fn = getattr(self.wallet.ln, method)
|
||||
return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
||||
|
||||
def _parse_state(self, state: str) -> ChannelState:
|
||||
if state == "CHANNELD_NORMAL":
|
||||
return ChannelState.ACTIVE
|
||||
if state in (
|
||||
# wait for force close
|
||||
"AWAITING_UNILATERAL",
|
||||
# waiting for close
|
||||
"CHANNELD_SHUTTING_DOWN",
|
||||
# waiting for open
|
||||
"CHANNELD_AWAITING_LOCKIN",
|
||||
"OPENINGD",
|
||||
):
|
||||
return ChannelState.PENDING
|
||||
if state in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
):
|
||||
return ChannelState.CLOSED
|
||||
return ChannelState.INACTIVE
|
||||
|
||||
@catch_rpc_errors
|
||||
async def connect_peer(self, uri: str):
|
||||
# https://docs.corelightning.org/reference/lightning-connect
|
||||
@@ -93,8 +119,8 @@ class CoreLightningNode(Node):
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
push_amount: int | None = None,
|
||||
fee_rate: int | None = None,
|
||||
) -> ChannelPoint:
|
||||
try:
|
||||
result = await self.ln_rpc(
|
||||
@@ -147,12 +173,14 @@ class CoreLightningNode(Node):
|
||||
@catch_rpc_errors
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
short_id: str | None = None,
|
||||
point: ChannelPoint | None = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if not short_id:
|
||||
raise HTTPException(status_code=400, detail="Short id required")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Short id required"
|
||||
)
|
||||
try:
|
||||
await self.ln_rpc("close", short_id)
|
||||
except RpcError as exc:
|
||||
@@ -196,48 +224,45 @@ class CoreLightningNode(Node):
|
||||
else:
|
||||
return NodePeerInfo(id=node["nodeid"])
|
||||
|
||||
@catch_rpc_errors
|
||||
async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int):
|
||||
await self.ln_rpc("setchannel", channel_id, feebase=base_msat, feeppm=ppm)
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
||||
channels = await self.get_channels()
|
||||
for channel in channels:
|
||||
if channel.id == channel_id:
|
||||
return channel
|
||||
return None
|
||||
|
||||
@catch_rpc_errors
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
funds = await self.ln_rpc("listfunds")
|
||||
channels = await self.ln_rpc("listpeerchannels")
|
||||
nodes = await self.ln_rpc("listnodes")
|
||||
nodes_by_id = {n["nodeid"]: n for n in nodes["nodes"]}
|
||||
|
||||
return [
|
||||
NodeChannel(
|
||||
id=ch["channel_id"],
|
||||
short_id=ch.get("short_channel_id"),
|
||||
point=ChannelPoint(
|
||||
funding_txid=ch["funding_txid"],
|
||||
output_index=ch["funding_output"],
|
||||
output_index=ch["funding_outnum"],
|
||||
),
|
||||
peer_id=ch["peer_id"],
|
||||
balance=ChannelBalance(
|
||||
local_msat=ch["our_amount_msat"],
|
||||
remote_msat=ch["amount_msat"] - ch["our_amount_msat"],
|
||||
total_msat=ch["amount_msat"],
|
||||
local_msat=ch["spendable_msat"],
|
||||
remote_msat=ch["receivable_msat"],
|
||||
total_msat=ch["total_msat"],
|
||||
),
|
||||
fee_ppm=ch["fee_proportional_millionths"],
|
||||
fee_base_msat=ch["fee_base_msat"],
|
||||
name=nodes_by_id.get(ch["peer_id"], {}).get("alias"),
|
||||
color=nodes_by_id.get(ch["peer_id"], {}).get("color"),
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if ch["state"] == "CHANNELD_NORMAL"
|
||||
else (
|
||||
ChannelState.PENDING
|
||||
if ch["state"] in ("CHANNELD_AWAITING_LOCKIN", "OPENINGD")
|
||||
else (
|
||||
ChannelState.CLOSED
|
||||
if ch["state"]
|
||||
in (
|
||||
"CHANNELD_CLOSING",
|
||||
"CLOSINGD_COMPLETE",
|
||||
"CLOSINGD_SIGEXCHANGE",
|
||||
"ONCHAIN",
|
||||
)
|
||||
else ChannelState.INACTIVE
|
||||
)
|
||||
)
|
||||
),
|
||||
state=self._parse_state(ch["state"]),
|
||||
)
|
||||
for ch in funds["channels"]
|
||||
for ch in channels["channels"]
|
||||
]
|
||||
|
||||
@catch_rpc_errors
|
||||
@@ -279,14 +304,19 @@ class CoreLightningNode(Node):
|
||||
return [
|
||||
NodePayment(
|
||||
bolt11=pay.get("bolt11"),
|
||||
amount=pay["amount_msat"],
|
||||
fee=int(pay["amount_msat"]) - int(pay["amount_sent_msat"]),
|
||||
amount=pay.get("amount_msat", 0),
|
||||
fee=int(pay.get("amount_msat", 0))
|
||||
- int(pay.get("amount_sent_msat", 0)),
|
||||
memo=pay.get("description"),
|
||||
time=pay["created_at"],
|
||||
preimage=pay.get("preimage"),
|
||||
payment_hash=pay["payment_hash"],
|
||||
pending=pay["status"] != "complete",
|
||||
destination=await self.get_peer_info(pay["destination"]),
|
||||
destination=(
|
||||
await self.get_peer_info(pay.get("destination"))
|
||||
if pay.get("destination")
|
||||
else None
|
||||
),
|
||||
)
|
||||
for pay in reversed(result["pays"])
|
||||
if pay["status"] != "failed"
|
||||
|
||||
+107
-20
@@ -4,7 +4,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from httpx import HTTPStatusError
|
||||
@@ -41,6 +41,14 @@ def _decode_bytes(data: str) -> str:
|
||||
return base64.b64decode(data).hex()
|
||||
|
||||
|
||||
def _encode_bytes(data: str) -> str:
|
||||
return base64.b64encode(bytes.fromhex(data)).decode()
|
||||
|
||||
|
||||
def _encode_urlsafe_bytes(data: str) -> str:
|
||||
return base64.urlsafe_b64encode(bytes.fromhex(data)).decode()
|
||||
|
||||
|
||||
def _parse_channel_point(raw: str) -> ChannelPoint:
|
||||
funding_tx, output_index = raw.split(":")
|
||||
return ChannelPoint(
|
||||
@@ -52,9 +60,7 @@ def _parse_channel_point(raw: str) -> ChannelPoint:
|
||||
class LndRestNode(Node):
|
||||
wallet: LndRestWallet
|
||||
|
||||
async def request(
|
||||
self, method: str, path: str, json: Optional[dict] = None, **kwargs
|
||||
):
|
||||
async def request(self, method: str, path: str, json: dict | None = None, **kwargs):
|
||||
response = await self.wallet.client.request(
|
||||
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
|
||||
)
|
||||
@@ -123,21 +129,18 @@ class LndRestNode(Node):
|
||||
self,
|
||||
peer_id: str,
|
||||
local_amount: int,
|
||||
push_amount: Optional[int] = None,
|
||||
fee_rate: Optional[int] = None,
|
||||
push_amount: int | None = None,
|
||||
fee_rate: int | None = None,
|
||||
) -> ChannelPoint:
|
||||
response = await self.request(
|
||||
"POST",
|
||||
"/v1/channels",
|
||||
data=json.dumps(
|
||||
{
|
||||
# 'node_pubkey': base64.b64encode(peer_id.encode()).decode(),
|
||||
"node_pubkey_string": peer_id,
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
}
|
||||
),
|
||||
json={
|
||||
"node_pubkey": _encode_bytes(peer_id),
|
||||
"sat_per_vbyte": fee_rate,
|
||||
"local_funding_amount": local_amount,
|
||||
"push_sat": push_amount,
|
||||
},
|
||||
)
|
||||
return ChannelPoint(
|
||||
# WHY IS THIS REVERSED?!
|
||||
@@ -159,14 +162,20 @@ class LndRestNode(Node):
|
||||
timeout=None,
|
||||
) as stream:
|
||||
async for chunk in stream.aiter_text():
|
||||
if chunk:
|
||||
chunk = json.loads(chunk)
|
||||
logger.info(f"LND Channel close update: {chunk['result']}")
|
||||
if not chunk:
|
||||
continue
|
||||
chunk = json.loads(chunk)
|
||||
if "error" in chunk:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=chunk["error"].get("message"),
|
||||
)
|
||||
logger.info(f"LND Channel close update: {chunk.get('result')}")
|
||||
|
||||
async def close_channel(
|
||||
self,
|
||||
short_id: Optional[str] = None,
|
||||
point: Optional[ChannelPoint] = None,
|
||||
short_id: str | None = None,
|
||||
point: ChannelPoint | None = None,
|
||||
force: bool = False,
|
||||
):
|
||||
if short_id:
|
||||
@@ -178,6 +187,81 @@ class LndRestNode(Node):
|
||||
|
||||
asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006
|
||||
|
||||
async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int):
|
||||
# https://lightning.engineering/api-docs/api/lnd/lightning/update-channel-policy/
|
||||
channel = await self.get_channel(channel_id)
|
||||
if not channel:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Channel not found"
|
||||
)
|
||||
if not channel.point:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
|
||||
)
|
||||
await self.request(
|
||||
"POST",
|
||||
"/v1/chanpolicy",
|
||||
json={
|
||||
"base_fee_msat": base_msat,
|
||||
"fee_rate_ppm": ppm,
|
||||
"chan_point": {
|
||||
"funding_txid_str": channel.point.funding_txid,
|
||||
"output_index": channel.point.output_index,
|
||||
},
|
||||
# https://docs.lightning.engineering/lightning-network-tools/lnd/optimal-configuration-of-a-routing-node#channel-defaults
|
||||
"time_lock_delta": 80,
|
||||
# 'max_htlc_msat': <uint64>,
|
||||
# 'min_htlc_msat': <uint64>,
|
||||
# 'inbound_fee': <InboundFee>,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_channel(self, channel_id: str) -> NodeChannel | None:
|
||||
channel_info = await self.get(f"/v1/graph/edge/{channel_id}")
|
||||
info = await self.get("/v1/getinfo")
|
||||
if info["identity_pubkey"] == channel_info["node1_pub"]:
|
||||
my_node_key = "node1"
|
||||
peer_node_key = "node2"
|
||||
else:
|
||||
my_node_key = "node2"
|
||||
peer_node_key = "node1"
|
||||
peer_id = channel_info[f"{peer_node_key}_pub"]
|
||||
peer_b64 = _encode_urlsafe_bytes(peer_id)
|
||||
channels = await self.get(f"/v1/channels?peer={peer_b64}")
|
||||
if "error" in channel_info and "error" in channels:
|
||||
logger.debug("LND get_channel", channels)
|
||||
return None
|
||||
if len(channels["channels"]) == 0:
|
||||
logger.debug(f"LND get_channel no channels founds with id {peer_b64}")
|
||||
return None
|
||||
for channel in channels["channels"]:
|
||||
if channel["chan_id"] == channel_id:
|
||||
peer_info = await self.get_peer_info(peer_id)
|
||||
return NodeChannel(
|
||||
id=channel.get("chan_id"),
|
||||
peer_id=peer_info.id,
|
||||
name=peer_info.alias,
|
||||
color=peer_info.color,
|
||||
state=(
|
||||
ChannelState.ACTIVE
|
||||
if channel["active"]
|
||||
else ChannelState.INACTIVE
|
||||
),
|
||||
fee_ppm=channel_info[f"{my_node_key}_policy"][
|
||||
"fee_rate_milli_msat"
|
||||
],
|
||||
fee_base_msat=channel_info[f"{my_node_key}_policy"][
|
||||
"fee_base_msat"
|
||||
],
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
remote_msat=msat(channel["remote_balance"]),
|
||||
total_msat=msat(channel["capacity"]),
|
||||
),
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_channels(self) -> list[NodeChannel]:
|
||||
normal, pending, closed = await asyncio.gather(
|
||||
self.get("/v1/channels"),
|
||||
@@ -197,6 +281,7 @@ class LndRestNode(Node):
|
||||
state=state,
|
||||
name=info.alias,
|
||||
color=info.color,
|
||||
id=channel.get("chan_id", "node is for pending channels"),
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
balance=ChannelBalance(
|
||||
local_msat=msat(channel["local_balance"]),
|
||||
@@ -216,6 +301,7 @@ class LndRestNode(Node):
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
id=channel.get("chan_id", "node is for closing channels"),
|
||||
peer_id=info.id,
|
||||
state=ChannelState.CLOSED,
|
||||
name=info.alias,
|
||||
@@ -233,6 +319,7 @@ class LndRestNode(Node):
|
||||
info = await self.get_peer_info(channel["remote_pubkey"])
|
||||
channels.append(
|
||||
NodeChannel(
|
||||
id=channel["chan_id"],
|
||||
short_id=channel["chan_id"],
|
||||
point=_parse_channel_point(channel["channel_point"]),
|
||||
peer_id=channel["remote_pubkey"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user